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}>} */ const queue = []; export class PasswordServiceBusyError extends Error { constructor() { super('Password service is busy. Try again shortly.'); this.name = 'PasswordServiceBusyError'; this.retryAfterSeconds = 2; } } /** @returns {Promise} */ 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} */ 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 ); }