/** * localCredentials.ts — password hashing and verification using node:crypto scrypt. * * D-08: Uses node:crypto scrypt — zero new npm dependencies; no native node-gyp build * in the Docker image. PHC-style encoded format allows parameter evolution without * a separate DB migration. * * Security properties: * - 16-byte per-hash random salt — unique salt per password prevents rainbow table attacks * - scrypt parameters: N=16384 (2^14), r=8, p=1 — OWASP-compatible * - 32-byte (256-bit) output key * - timingSafeEqual for constant-time comparison — prevents timing oracle attacks * - verifyPassword never throws — returns false on any parse/format/crypto error * - Passwords are never logged * * Encoded format: scrypt$N$r$p$$ * Example: scrypt$16384$8$1$<22-char-b64url>$<43-char-b64url> * Max length: ~83 chars — fits in varchar(256) password_hash column */ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; import type { BinaryLike, ScryptOptions } from 'node:crypto'; // WR-03: use the ASYNC scrypt (libuv threadpool) so password hashing does NOT block the // single Node event loop. scryptSync ran on the main thread, so a burst of unauthenticated // POST /local/login requests (each running scrypt N=16384, ~tens of ms of CPU, including the // always-run dummy-hash path) could pin the loop and stall ALL other API traffic — a cheap // unauthenticated DoS. This async wrapper offloads the CPU to the threadpool, preserving the // timing-defense property while keeping the loop responsive. // // A hand-rolled Promise wrapper is used (rather than promisify) because promisify's typings // do not cover the options-carrying scrypt overload (N/r/p). function scryptAsync( password: BinaryLike, salt: BinaryLike, keylen: number, options: ScryptOptions, ): Promise { return new Promise((resolve, reject) => { scrypt(password, salt, keylen, options, (err, derivedKey) => { if (err) reject(err); else resolve(derivedKey); }); }); } // OWASP-compatible scrypt parameters for password hashing const SCRYPT_N = 16384; // CPU/memory cost factor (2^14) const SCRYPT_R = 8; // block size const SCRYPT_P = 1; // parallelization factor const KEY_LEN = 32; // 256-bit derived key output /** * Hash a password using scrypt with a random 16-byte salt. * * Returns a self-describing PHC-style encoded string: * scrypt$N$r$p$$ * * The encoded format embeds all parameters so verifyPassword can re-derive * the hash without relying on hardcoded constants — supports future parameter * migration without a DB schema change. * * WR-03: async — scrypt runs on the libuv threadpool, not the event loop. */ export async function hashPassword(password: string): Promise { const salt = randomBytes(16); const hash = await scryptAsync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, }); return [ 'scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url'), ].join('$'); } /** * Verify a password against a stored PHC-encoded hash. * * Parses the algorithm parameters from the stored string, re-derives the * candidate hash using scryptSync, and compares with timingSafeEqual to * prevent timing-oracle attacks. * * Security: * - timingSafeEqual: requires equal-length buffers; storedHash.length as keylen * ensures this regardless of the stored KEY_LEN at hash time. * - Returns false (never throws) on any parse error, invalid base64url, or * scrypt parameter error — safe to call with untrusted input. * - Never logs the candidate password. * * WR-03: async — scrypt runs on the libuv threadpool, not the event loop. * * @returns Promise if candidate matches the stored hash; Promise otherwise * (including all parse/format/crypto errors — never rejects). */ export async function verifyPassword(storedEncoded: string, candidate: string): Promise { try { const parts = storedEncoded.split('$'); if (parts.length !== 6) return false; const [, n, r, p, saltB64, hashB64] = parts; const salt = Buffer.from(saltB64, 'base64url'); const storedHash = Buffer.from(hashB64, 'base64url'); if (salt.length === 0 || storedHash.length === 0) return false; const candidateHash = await scryptAsync(candidate, salt, storedHash.length, { N: Number(n), r: Number(r), p: Number(p), }); return timingSafeEqual(storedHash, candidateHash); } catch { // Catch any scrypt parameter errors, buffer errors, or other crypto exceptions. // Never propagate — return false for all error cases. return false; } }