import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; const N = 32768; const R = 8; const P = 1; const KEYLEN = 32; // Upper bounds on scrypt params accepted from a stored hash. A crafted users.json must not // be able to turn verification into an unbounded CPU sink. const MAX_N = 65536; const MAX_R = 16; const MAX_P = 4; // Cap concurrent scrypt calls so a burst of logins can't exhaust CPU/memory; extra callers queue. const MAX_CONCURRENT_SCRYPT = 4; // …and cap the queue itself, so a flood parks an unbounded number of requests instead of // being shed. Callers past the cap fail fast rather than waiting minutes for a slot. const MAX_QUEUED_SCRYPT = 64; let activeScryptCalls = 0; const scryptWaitQueue: Array<() => void> = []; /** Thrown when the scrypt wait queue is full; callers surface it as a 503, not a bad password. */ export class ScryptOverloadError extends Error { constructor() { super('Too many concurrent login attempts'); this.name = 'ScryptOverloadError'; } } async function withScryptSlot(fn: () => Promise): Promise { if (activeScryptCalls >= MAX_CONCURRENT_SCRYPT) { if (scryptWaitQueue.length >= MAX_QUEUED_SCRYPT) throw new ScryptOverloadError(); await new Promise((resolve) => scryptWaitQueue.push(resolve)); } activeScryptCalls++; try { return await fn(); } finally { activeScryptCalls--; const next = scryptWaitQueue.shift(); if (next) next(); } } function scryptAsync(password: string, salt: Buffer, n: number, r: number, p: number): Promise { return withScryptSlot( () => new Promise((resolve, reject) => { scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => (err ? reject(err) : resolve(key))); }) ); } export const MIN_PASSWORD_LENGTH = 8; export async function hashPassword(password: string): Promise { const salt = randomBytes(16); const key = await scryptAsync(password, salt, N, R, P); return ['scrypt', N, R, P, salt.toString('base64'), key.toString('base64')].join('$'); } export async function verifyPassword(password: string, stored: string): Promise { const parts = stored.split('$'); if (parts.length !== 6 || parts[0] !== 'scrypt') return false; const n = Number(parts[1]); const r = Number(parts[2]); const p = Number(parts[3]); if (![n, r, p].every((x) => Number.isInteger(x) && x > 0)) return false; if (n > MAX_N || r > MAX_R || p > MAX_P) return false; try { const salt = Buffer.from(parts[4], 'base64'); const expected = Buffer.from(parts[5], 'base64'); const actual = await scryptAsync(password, salt, n, r, p); return expected.length === actual.length && timingSafeEqual(expected, actual); } catch (e) { // Overload is a server condition, not a wrong password: let the caller turn it into a 503. if (e instanceof ScryptOverloadError) throw e; return false; } }