import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; const scrypt = promisify(scryptCb) as (password: string | Buffer, salt: Buffer, keylen: number, opts: { N: number; r: number; p: number; maxmem: number }) => Promise; /** Zero-native-dependency password hashing: scrypt (N=2^15, r=8, p=1), self-describing format. */ const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64, maxmem: 64 * 1024 * 1024 } as const; export async function hashPassword(password: string): Promise { const salt = randomBytes(16); const key = await scrypt(password.normalize('NFKC'), salt, SCRYPT.keylen, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, maxmem: SCRYPT.maxmem }); return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64url')}$${key.toString('base64url')}`; } export async function verifyPassword(password: string, stored: string | null | undefined): Promise { if (!stored) return false; const [algo, n, r, p, saltB64, hashB64] = stored.split('$'); if (algo !== 'scrypt' || !n || !r || !p || !saltB64 || !hashB64) return false; const expected = Buffer.from(hashB64, 'base64url'); const key = await scrypt(password.normalize('NFKC'), Buffer.from(saltB64, 'base64url'), expected.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem }); return key.length === expected.length && timingSafeEqual(key, expected); } function secret(): string { const s = process.env.SESSION_SECRET; if (!s || s.length < 16) throw new Error('SESSION_SECRET must be set (≥ 32 bytes recommended)'); return s; } /** Keyed hash for opaque tokens (session ids, device tokens, API keys, one-time codes). */ export function hmacToken(value: string, purpose = 'token'): string { return createHmac('sha256', secret()).update(`${purpose}:${value}`).digest('base64url'); } export function sha256Hex(value: string): string { return createHash('sha256').update(value).digest('hex'); } export function randomToken(bytes = 32): string { return randomBytes(bytes).toString('base64url'); } /** Uniformly random n-digit numeric code (no modulo bias: randomInt). */ export function numericCode(digits = 6): string { const max = 10 ** digits; return String(randomInt(0, max)).padStart(digits, '0'); } /** Human-friendly recovery code: 10 chars, groups of 5, unambiguous alphabet. */ export function recoveryCode(): string { const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; let out = ''; for (let i = 0; i < 10; i++) out += alphabet[randomInt(0, alphabet.length)]; return `${out.slice(0, 5)}-${out.slice(5)}`; } export function normalizeRecoveryCode(input: string): string { return input.toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{5})(.{5})$/, '$1-$2'); } function aesKey(): Buffer { return createHash('sha256').update(`aes:${secret()}`).digest(); } /** AES-256-GCM encryption for secrets at rest (TOTP seeds). */ export function encryptSecret(plain: string): string { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', aesKey(), iv); const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${enc.toString('base64url')}`; } export function decryptSecret(payload: string): string { const [v, ivB, tagB, encB] = payload.split('.'); if (v !== 'v1' || !ivB || !tagB || !encB) throw new Error('bad secret payload'); const decipher = createDecipheriv('aes-256-gcm', aesKey(), Buffer.from(ivB, 'base64url')); decipher.setAuthTag(Buffer.from(tagB, 'base64url')); return Buffer.concat([decipher.update(Buffer.from(encB, 'base64url')), decipher.final()]).toString('utf8'); } /** Signed, expiring compact token (pending-login state, reset links). Payload is JSON, base64url. */ export function signPayload(payload: Record, ttlSeconds: number): string { const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds })).toString('base64url'); const sig = createHmac('sha256', secret()).update(body).digest('base64url'); return `${body}.${sig}`; } export function verifyPayload>(token: string | null | undefined): (T & { exp: number }) | null { if (!token) return null; const [body, sig] = token.split('.'); if (!body || !sig) return null; const expected = createHmac('sha256', secret()).update(body).digest('base64url'); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; try { const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as T & { exp: number }; if (!parsed.exp || parsed.exp < Math.floor(Date.now() / 1000)) return null; return parsed; } catch { return null; } } export function safeEqual(a: string, b: string): boolean { const ba = Buffer.from(a); const bb = Buffer.from(b); return ba.length === bb.length && timingSafeEqual(ba, bb); }