import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto' // scrypt parameters from the OWASP Password Storage Cheat Sheet (N=2^15, r=8, p=3: ~32 MiB per hash). const params = { N: 2 ** 15, r: 8, p: 3 } const keyLength = 64 function derive(password: string, salt: Buffer, options: ScryptOptions): Promise { return new Promise((resolve, reject) => scrypt(password.normalize('NFKC'), salt, keyLength, { ...options, maxmem: 64 * 1024 * 1024 }, (error, key) => (error ? reject(error) : resolve(key))), ) } /** Returns `scrypt$N$r$p$salt$hash` so parameters can be raised later without breaking old hashes. */ export async function hashPassword(password: string): Promise { const salt = randomBytes(16) const key = await derive(password, salt, params) return ['scrypt', params.N, params.r, params.p, salt.toString('base64'), key.toString('base64')].join('$') } export async function verifyPassword(password: string, stored: string): Promise { const [algorithm, N, r, p, salt, hash] = stored.split('$') if (algorithm !== 'scrypt' || !salt || !hash) return false const expected = Buffer.from(hash, 'base64') const actual = await derive(password, Buffer.from(salt, 'base64'), { N: Number(N), r: Number(r), p: Number(p) }) return actual.length === expected.length && timingSafeEqual(actual, expected) } /** Burns comparable time when the user doesn't exist, so login timing doesn't reveal valid emails. */ let dummyHash: Promise | undefined export async function verifyAgainstDummy(password: string): Promise { dummyHash ??= hashPassword('dummy-password-for-timing') await verifyPassword(password, await dummyHash) return false }