import { log2BigInt } from './entropy'; import { randomInts } from './random'; export const CHARACTER_CLASSES = { upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', lower: 'abcdefghijklmnopqrstuvwxyz', digits: '0123456789', symbols: '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~', } as const; export type CharacterClass = keyof typeof CHARACTER_CLASSES; /** Characters that are easy to misread or mistype by hand. */ export const AMBIGUOUS_CHARACTERS = 'Il1|O0o\'"`'; export const MAX_PASSWORD_LENGTH = 1024; export type PasswordOptions = { length: number; classes: Record; excludeAmbiguous: boolean; }; export class PasswordOptionsError extends Error { override name = 'PasswordOptionsError'; } /** The enabled character classes after exclusions, each as a string of allowed characters. */ export function characterPools(options: PasswordOptions): string[] { return (Object.keys(CHARACTER_CLASSES) as CharacterClass[]) .filter((name) => options.classes[name]) .map((name) => [...CHARACTER_CLASSES[name]] .filter((char) => !options.excludeAmbiguous || !AMBIGUOUS_CHARACTERS.includes(char)) .join(''), ); } function validate(options: PasswordOptions): string[] { const { length } = options; if (!Number.isInteger(length) || length < 1 || length > MAX_PASSWORD_LENGTH) { throw new PasswordOptionsError( `Length must be a whole number from 1 to ${MAX_PASSWORD_LENGTH}.`, ); } const pools = characterPools(options); if (pools.length === 0) { throw new PasswordOptionsError('Select at least one character type.'); } if (length < pools.length) { throw new PasswordOptionsError( `A ${length}-character password cannot contain all ${pools.length} selected character types.`, ); } return pools; } const MAX_ATTEMPTS = 10_000; /** * Generates a password containing at least one character from every enabled class. * * Each character is drawn uniformly from the union of the enabled classes; a candidate * missing a class is discarded and redrawn whole. That makes every valid password exactly * equally likely and fixes no class to any position. (Placing one character per class and * shuffling does not: it skews the distribution of class counts.) */ export function generatePassword(options: PasswordOptions): string { const pools = validate(options); const alphabet = pools.join(''); // The alphabet is the pools back to back; classOf[i] is the class of alphabet[i]. const classOf = pools.flatMap((pool, index) => Array.from(pool, () => index)); for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { const picks = randomInts(alphabet.length, options.length); if (new Set(picks.map((pick) => classOf[pick])).size === pools.length) { return picks.map((pick) => alphabet[pick]).join(''); } } // Unreachable in practice: the worst valid case accepts ~6% of candidates. throw new PasswordOptionsError('Could not satisfy the selected character types.'); } /** * Exact entropy of generatePassword's output: log2 of the number of passwords that contain * every enabled class, counted by inclusion-exclusion over the classes. */ export function passwordEntropyBits(options: PasswordOptions): number { const pools = validate(options); const length = BigInt(options.length); let count = 0n; for (let mask = 0; mask < 1 << pools.length; mask += 1) { let missingSize = 0; let missingClasses = 0; pools.forEach((pool, index) => { if (mask & (1 << index)) { missingSize += pool.length; missingClasses += 1; } }); const term = BigInt(pools.join('').length - missingSize) ** length; count += missingClasses % 2 === 0 ? term : -term; } return log2BigInt(count); }