import { randomInts } from './random'; export const PIN_MIN_LENGTH = 4; export const PIN_MAX_LENGTH = 12; export type PinOptions = { length: number; /** Reject the same digit twice in a row (1123, 4555). */ noRepeats: boolean; /** Reject three digits running up or down by one (123, 987); 890 is not a run. */ noSequences: boolean; }; export class PinOptionsError extends Error { override name = 'PinOptionsError'; } function validate({ length }: PinOptions) { if (!Number.isInteger(length) || length < PIN_MIN_LENGTH || length > PIN_MAX_LENGTH) { throw new PinOptionsError( `PIN length must be a whole number from ${PIN_MIN_LENGTH} to ${PIN_MAX_LENGTH}.`, ); } } /** True when `digit` may follow `previous` digits under the options. */ function allowedNext(previous: number[], digit: number, options: PinOptions): boolean { const last = previous.at(-1); if (last === undefined) return true; if (options.noRepeats && digit === last) return false; const beforeLast = previous.at(-2); if (options.noSequences && beforeLast !== undefined) { const step = digit - last; if ((step === 1 || step === -1) && last - beforeLast === step) return false; } return true; } export function isPinAllowed(pin: string, options: PinOptions): boolean { const digits = [...pin].map(Number); return digits.every((digit, index) => allowedNext(digits.slice(0, index), digit, options)); } const MAX_ATTEMPTS = 10_000; /** Uniform over all PINs of the length that pass the options (whole-PIN rejection). */ export function generatePin(options: PinOptions): string { validate(options); for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { const pin = randomInts(10, options.length).join(''); if (isPinAllowed(pin, options)) return pin; } // Unreachable in practice: at 12 digits with both rules ~30% of candidates pass. throw new PinOptionsError('Could not generate a PIN with these options.'); } /** Number of PINs that pass the options, counted exactly by dynamic programming. */ export function countPins(options: PinOptions): number { validate(options); // counts[last * 3 + stepKind]: stepKind 0 = no run, 1 = last step +1, 2 = last step -1 let counts = new Array(30).fill(0); for (let digit = 0; digit < 10; digit += 1) counts[digit * 3] = 1; for (let position = 1; position < options.length; position += 1) { const next = new Array(30).fill(0); counts.forEach((ways, state) => { if (ways === 0) return; const last = Math.floor(state / 3); const runStep = [0, 1, -1][state % 3]; for (let digit = 0; digit < 10; digit += 1) { const step = digit - last; if (options.noRepeats && step === 0) continue; if (options.noSequences && step !== 0 && step === runStep) continue; const kind = step === 1 ? 1 : step === -1 ? 2 : 0; next[digit * 3 + kind] += ways; } }); counts = next; } return counts.reduce((sum, ways) => sum + ways, 0); } export function pinEntropyBits(options: PinOptions): number { return Math.log2(countPins(options)); }