import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; const scrypt = promisify(scryptCb) as ( password: string | Buffer, salt: string | Buffer, keylen: number, options: { N: number; r: number; p: number; maxmem: number }, ) => Promise; /** * scrypt parameters. N=2^15 costs roughly 100ms on a modern laptop, which is * the point: it makes an offline attack on a stolen `_wellformed_user` table * expensive without making login feel slow. */ const PARAMS = { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }; const KEY_LENGTH = 64; const SALT_LENGTH = 16; export const MIN_PASSWORD_LENGTH = 10; export const MAX_PASSWORD_LENGTH = 200; export async function hashPassword(password: string): Promise { const salt = randomBytes(SALT_LENGTH); const key = await scrypt(password.normalize('NFKC'), salt, KEY_LENGTH, PARAMS); return ['scrypt', PARAMS.N, PARAMS.r, PARAMS.p, salt.toString('base64'), key.toString('base64')].join('$'); } /** Constant-time comparison; returns false rather than throwing on junk input. */ export async function verifyPassword(password: string, stored: string): Promise { const parts = stored.split('$'); if (parts.length !== 6 || parts[0] !== 'scrypt') return false; const [, nRaw, rRaw, pRaw, saltRaw, keyRaw] = parts as [string, string, string, string, string, string]; const N = Number(nRaw); const r = Number(rRaw); const p = Number(pRaw); if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return false; let expected: Buffer; let actual: Buffer; try { expected = Buffer.from(keyRaw, 'base64'); actual = await scrypt(password.normalize('NFKC'), Buffer.from(saltRaw, 'base64'), expected.length, { N, r, p, maxmem: PARAMS.maxmem, }); } catch { return false; } return expected.length === actual.length && timingSafeEqual(expected, actual); } export interface PasswordProblem { message: string; } export function checkPasswordStrength(password: string): PasswordProblem | null { if (password.length < MIN_PASSWORD_LENGTH) { return { message: `Passwords must be at least ${MIN_PASSWORD_LENGTH} characters.` }; } if (password.length > MAX_PASSWORD_LENGTH) { return { message: `Passwords must be at most ${MAX_PASSWORD_LENGTH} characters.` }; } if (/^\s|\s$/.test(password)) { return { message: 'Passwords cannot start or end with whitespace.' }; } if (COMMON_PASSWORDS.has(password.toLowerCase())) { return { message: 'That password is too common. Choose something else.' }; } return null; } const COMMON_PASSWORDS = new Set([ 'password', 'password1', 'password123', 'passw0rd123', '1234567890', '12345678901', 'qwertyuiop', 'letmein123', 'admin12345', 'administrator', 'welcome123', 'iloveyou123', 'changeme123', ]);