/** * Secret hashing at rest (INV5). Pairing codes and v0.8 tokens are stored as salted scrypt * hashes, never raw. Verification is constant-time. scrypt is a Node builtin (no native dep); * argon2id would be the production upgrade (integration note) but scrypt satisfies INV5 here. */ import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto' /** * Deterministic hash for high-entropy lookup keys (pairing codes: ≥128-bit input, so a fast * preimage-resistant hash is safe at rest — the input space is infeasible to brute-force, INV5). * A salted scrypt cannot be used here because redemption must find the row by hash of the * presented code. Production may HMAC this with a server-side pepper (integration note). */ export function sha256Hex(raw: string): string { return createHash('sha256').update(raw, 'utf8').digest('hex') } const SCRYPT_KEYLEN = 32 const SCRYPT_COST = 1 << 14 // N=16384 — modest, fast enough for tests, memory-hard const SALT_LEN = 16 /** Produce a self-describing `scrypt$$` string. Raw secret is discarded. */ export function hashSecret(raw: string): string { const salt = randomBytes(SALT_LEN) const hash = scryptSync(raw, salt, SCRYPT_KEYLEN, { N: SCRYPT_COST }) return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}` } /** Constant-time verify a raw secret against a stored `scrypt$salt$hash`. */ export function verifySecret(raw: string, stored: string): boolean { const parts = stored.split('$') if (parts.length !== 3 || parts[0] !== 'scrypt') return false const saltHex = parts[1] as string const hashHex = parts[2] as string let expected: Buffer try { expected = Buffer.from(hashHex, 'hex') const salt = Buffer.from(saltHex, 'hex') const actual = scryptSync(raw, salt, expected.length, { N: SCRYPT_COST }) return timingSafeEqual(actual, expected) } catch { return false } }