You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/packages/auth/src/pin.ts

57 lines
2.5 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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