|
|
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'))
|
|
|
}
|