TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';2import { promisify } from 'node:util';34const scrypt = promisify(scryptCb) as (password: string | Buffer, salt: Buffer, keylen: number, opts: { N: number; r: number; p: number; maxmem: number }) => Promise<Buffer>;56/** Zero-native-dependency password hashing: scrypt (N=2^15, r=8, p=1), self-describing format. */7const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64, maxmem: 64 * 1024 * 1024 } as const;89export async function hashPassword(password: string): Promise<string> {10 const salt = randomBytes(16);11 const key = await scrypt(password.normalize('NFKC'), salt, SCRYPT.keylen, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, maxmem: SCRYPT.maxmem });12 return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64url')}$${key.toString('base64url')}`;13}1415export async function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean> {16 if (!stored) return false;17 const [algo, n, r, p, saltB64, hashB64] = stored.split('$');18 if (algo !== 'scrypt' || !n || !r || !p || !saltB64 || !hashB64) return false;19 const expected = Buffer.from(hashB64, 'base64url');20 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 });21 return key.length === expected.length && timingSafeEqual(key, expected);22}2324function secret(): string {25 const s = process.env.SESSION_SECRET;26 if (!s || s.length < 16) throw new Error('SESSION_SECRET must be set (≥ 32 bytes recommended)');27 return s;28}2930/** Keyed hash for opaque tokens (session ids, device tokens, API keys, one-time codes). */31export function hmacToken(value: string, purpose = 'token'): string {32 return createHmac('sha256', secret()).update(`${purpose}:${value}`).digest('base64url');33}3435export function sha256Hex(value: string): string {36 return createHash('sha256').update(value).digest('hex');37}3839export function randomToken(bytes = 32): string {40 return randomBytes(bytes).toString('base64url');41}4243/** Uniformly random n-digit numeric code (no modulo bias: randomInt). */44export function numericCode(digits = 6): string {45 const max = 10 ** digits;46 return String(randomInt(0, max)).padStart(digits, '0');47}4849/** Human-friendly recovery code: 10 chars, groups of 5, unambiguous alphabet. */50export function recoveryCode(): string {51 const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';52 let out = '';53 for (let i = 0; i < 10; i++) out += alphabet[randomInt(0, alphabet.length)];54 return `${out.slice(0, 5)}-${out.slice(5)}`;55}5657export function normalizeRecoveryCode(input: string): string {58 return input.toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{5})(.{5})$/, '$1-$2');59}6061function aesKey(): Buffer {62 return createHash('sha256').update(`aes:${secret()}`).digest();63}6465/** AES-256-GCM encryption for secrets at rest (TOTP seeds). */66export function encryptSecret(plain: string): string {67 const iv = randomBytes(12);68 const cipher = createCipheriv('aes-256-gcm', aesKey(), iv);69 const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);70 const tag = cipher.getAuthTag();71 return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${enc.toString('base64url')}`;72}7374export function decryptSecret(payload: string): string {75 const [v, ivB, tagB, encB] = payload.split('.');76 if (v !== 'v1' || !ivB || !tagB || !encB) throw new Error('bad secret payload');77 const decipher = createDecipheriv('aes-256-gcm', aesKey(), Buffer.from(ivB, 'base64url'));78 decipher.setAuthTag(Buffer.from(tagB, 'base64url'));79 return Buffer.concat([decipher.update(Buffer.from(encB, 'base64url')), decipher.final()]).toString('utf8');80}8182/** Signed, expiring compact token (pending-login state, reset links). Payload is JSON, base64url. */83export function signPayload(payload: Record<string, unknown>, ttlSeconds: number): string {84 const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds })).toString('base64url');85 const sig = createHmac('sha256', secret()).update(body).digest('base64url');86 return `${body}.${sig}`;87}8889export function verifyPayload<T extends Record<string, unknown>>(token: string | null | undefined): (T & { exp: number }) | null {90 if (!token) return null;91 const [body, sig] = token.split('.');92 if (!body || !sig) return null;93 const expected = createHmac('sha256', secret()).update(body).digest('base64url');94 const a = Buffer.from(sig);95 const b = Buffer.from(expected);96 if (a.length !== b.length || !timingSafeEqual(a, b)) return null;97 try {98 const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as T & { exp: number };99 if (!parsed.exp || parsed.exp < Math.floor(Date.now() / 1000)) return null;100 return parsed;101 } catch {102 return null;103 }104}105106export function safeEqual(a: string, b: string): boolean {107 const ba = Buffer.from(a);108 const bb = Buffer.from(b);109 return ba.length === bb.length && timingSafeEqual(ba, bb);110}111