import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' /** * POS login is name-tile + PIN (09-UX §1: PIN-fast shift open); back office is * username + password. Both hash through here — scrypt, per-credential salt. */ export interface PinHash { salt: string // hex hash: string // hex } /** * M3 (red-team): a server-held pepper, HMAC-SHA256'd into the secret BEFORE scrypt. A stolen DB * gives an attacker the per-credential salts and hashes, but a 4-digit PIN's keyspace is only * 10,000 — trivially brute-forced with the salt, no matter how costly scrypt is. The pepper lives * only in the server's environment (`SIMS_PIN_PEPPER`), NEVER in the DB, so a DB-only theft leaves * the attacker unable to compute a single candidate hash: the PIN is peppered under a key they do * not have. When the env is unset we hash the raw secret exactly as before (dev logins keep * working) and warn ONCE that production MUST set it. Enabling/rotating the pepper invalidates * every existing hash → PINs must be re-enrolled (a dev re-seed does this since seed calls hashPin). */ let pepperWarned = false function peppered(secret: string): string | Buffer { const pepper = process.env['SIMS_PIN_PEPPER'] if (pepper === undefined || pepper === '') { if (!pepperWarned) { pepperWarned = true // eslint-disable-next-line no-console console.warn( '[auth] SIMS_PIN_PEPPER is not set — PIN/password hashes are un-peppered. A stolen DB could ' + 'be brute-forced against the tiny PIN keyspace. Production MUST set a server-held pepper.', ) } return secret // byte-identical to the pre-pepper scrypt input } return createHmac('sha256', pepper).update(secret, 'utf8').digest() } export function assertUsablePin(pin: string): void { if (!/^\d{4,6}$/.test(pin)) throw new Error('PIN must be 4–6 digits') if (/^(\d)\1+$/.test(pin)) throw new Error('PIN must not be a single repeated digit') if ('0123456789'.includes(pin) || '9876543210'.includes(pin)) { throw new Error('PIN must not be a simple sequence') } } export function hashPin(pin: string): PinHash { const salt = randomBytes(16) const hash = scryptSync(peppered(pin), salt, 32) return { salt: salt.toString('hex'), hash: hash.toString('hex') } } export function verifyPin(pin: string, stored: PinHash): boolean { const candidate = scryptSync(peppered(pin), Buffer.from(stored.salt, 'hex'), 32) return timingSafeEqual(candidate, Buffer.from(stored.hash, 'hex')) }