101 lines
3.3 KiB
JavaScript
101 lines
3.3 KiB
JavaScript
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
|
|
// OWASP's scrypt baseline: 128 MiB, r=8, p=1. Bound concurrent work so
|
|
// unauthenticated password attempts cannot allocate unlimited memory.
|
|
const parameters = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
|
|
const maxActive = 2;
|
|
const maxQueued = 16;
|
|
const queueTimeoutMs = 2000;
|
|
let active = 0;
|
|
/** @type {Array<{resolve: () => void, timer: ReturnType<typeof setTimeout>}>} */
|
|
const queue = [];
|
|
|
|
export class PasswordServiceBusyError extends Error {
|
|
constructor() {
|
|
super('Password service is busy. Try again shortly.');
|
|
this.name = 'PasswordServiceBusyError';
|
|
this.retryAfterSeconds = 2;
|
|
}
|
|
}
|
|
|
|
/** @returns {Promise<void>} */
|
|
function acquireSlot() {
|
|
if (active < maxActive) {
|
|
active++;
|
|
return Promise.resolve();
|
|
}
|
|
// Bound waiting requests as well as running hashes during sustained overload.
|
|
if (queue.length >= maxQueued) return Promise.reject(new PasswordServiceBusyError());
|
|
return new Promise((resolve, reject) => {
|
|
const entry = {
|
|
resolve,
|
|
timer: setTimeout(() => {
|
|
queue.splice(queue.indexOf(entry), 1);
|
|
reject(new PasswordServiceBusyError());
|
|
}, queueTimeoutMs)
|
|
};
|
|
queue.push(entry);
|
|
});
|
|
}
|
|
|
|
function releaseSlot() {
|
|
const next = queue.shift();
|
|
if (next) {
|
|
clearTimeout(next.timer);
|
|
// Transfer the occupied slot directly so new requests cannot jump the queue.
|
|
next.resolve();
|
|
} else {
|
|
active--;
|
|
}
|
|
}
|
|
|
|
/** @param {unknown} password */
|
|
export function validatePassword(password) {
|
|
if (typeof password !== 'string' || [...password].length < 8 || [...password].length > 128) {
|
|
throw new Error('Use a password or passphrase between 8 and 128 characters.');
|
|
}
|
|
return password;
|
|
}
|
|
|
|
/** @param {string} password @param {string} salt @returns {Promise<Buffer>} */
|
|
async function derive(password, salt) {
|
|
await acquireSlot();
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
scrypt(password, salt, 64, parameters, (error, result) => {
|
|
if (error) reject(error);
|
|
else resolve(result);
|
|
});
|
|
});
|
|
} finally {
|
|
releaseSlot();
|
|
}
|
|
}
|
|
|
|
/** @param {unknown} password */
|
|
export async function hashPassword(password) {
|
|
const validated = validatePassword(password);
|
|
const salt = randomBytes(32).toString('hex');
|
|
const key = await derive(validated, salt);
|
|
return `scrypt$131072$8$1$${salt}$${key.toString('hex')}`;
|
|
}
|
|
|
|
/** @param {unknown} password @param {string | null} encoded */
|
|
export async function verifyPassword(password, encoded) {
|
|
if (typeof password !== 'string' || [...password].length > 128) return false;
|
|
const parts = encoded?.split('$');
|
|
const valid =
|
|
parts?.length === 6 &&
|
|
parts[0] === 'scrypt' &&
|
|
parts[1] === '131072' &&
|
|
parts[2] === '8' &&
|
|
parts[3] === '1' &&
|
|
/^[a-f0-9]{64}$/.test(parts[4]) &&
|
|
/^[a-f0-9]{128}$/.test(parts[5]);
|
|
// Missing accounts perform the same expensive operation as real accounts.
|
|
const result = await derive(password, valid ? parts[4] : '0'.repeat(64));
|
|
return (
|
|
timingSafeEqual(result, Buffer.from(valid ? parts[5] : '0'.repeat(128), 'hex')) && !!valid
|
|
);
|
|
}
|