import { randomBytes, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto'; import { promisify } from 'node:util'; const scrypt = promisify(scryptCb) as ( password: string, salt: Buffer, keyLen: number, ) => Promise; const KEY_LENGTH = 64; const SALT_LENGTH = 16; /** * Hash a password with a fresh random salt. Stored format is * `salt:keyHex` (both as hex strings) so verification can re-derive without * a separate salt column. */ export async function hashPassword(password: string): Promise { const salt = randomBytes(SALT_LENGTH); const key = await scrypt(password, salt, KEY_LENGTH); return `${salt.toString('hex')}:${key.toString('hex')}`; } /** * Constant-time check of a candidate password against a stored * `salt:keyHex` hash. Returns false on any malformed input rather than * throwing — callers should treat false uniformly. */ export async function verifyPassword(password: string, stored: string): Promise { const parts = stored.split(':'); if (parts.length !== 2) return false; const [saltHex, keyHex] = parts; if (!saltHex || !keyHex) return false; let salt: Buffer; let expected: Buffer; try { salt = Buffer.from(saltHex, 'hex'); expected = Buffer.from(keyHex, 'hex'); } catch { return false; } if (expected.length !== KEY_LENGTH) return false; const candidate = await scrypt(password, salt, KEY_LENGTH); return timingSafeEqual(candidate, expected); } /** * Mint a fresh raw token (returned to the caller) and its SHA-256 hash * (stored in the DB). The raw token is meant to be embedded in a one-shot * URL; only the hash persists. */ export function mintToken(byteLength = 32): { raw: string; hash: string } { const raw = randomBytes(byteLength).toString('base64url'); const hash = createHash('sha256').update(raw).digest('hex'); return { raw, hash }; } export function hashToken(raw: string): string { return createHash('sha256').update(raw).digest('hex'); }