import type { Rng } from "@suaveplan/types"; import { DIGITS, SYMBOLS } from "../charsets/charsets.js"; import { estimatePassphraseEntropy } from "../entropy/entropy.js"; import { pwgenError } from "../errors/codes.js"; import { recordError, recordGeneration } from "../observability/metrics.js"; import { defaultRng } from "../random/default-rng.js"; import { randomInt } from "../random/random-int.js"; import { GeneratePassphraseOptionsSchema } from "../schemas/schemas.js"; import { getWordlist, MIN_WORDLIST_SIZE } from "../wordlists/registry.js"; import type { GeneratePassphraseOptions, WordlistRef, } from "./passphrase.types.js"; const DEFAULT_LENGTH = 6; const DEFAULT_SEPARATOR = "-"; function resolveWordlist(ref: WordlistRef | undefined): readonly string[] { if (ref === undefined || ref === "long") return getWordlist("eff-long"); if (ref === "short") return getWordlist("eff-short"); if (typeof ref === "string") return getWordlist(ref); if (ref.length < MIN_WORDLIST_SIZE) { throw pwgenError( "PWGEN.WORDLIST_TOO_SHORT", `inline wordlist has ${ref.length} entries; minimum is ${MIN_WORDLIST_SIZE}` ); } return ref; } function capitalizeFirst(word: string): string { return word.charAt(0).toUpperCase() + word.slice(1); } function applyCapitalization( words: string[], mode: "none" | "firstChar" | "randomWord", rng: Rng ): string[] { if (mode === "none") return words; if (mode === "firstChar") return words.map(capitalizeFirst); const pickIdx = randomInt(rng, words.length); return words.map((w, i) => (i === pickIdx ? capitalizeFirst(w) : w)); } function injectChars( base: string, count: number, source: ReadonlySet, rng: Rng ): string { if (count === 0) return base; const chars = [...source]; let acc = base; for (let n = 0; n < count; n++) { const insertAt = randomInt(rng, acc.length + 1); const ch = chars[randomInt(rng, chars.length)] as string; acc = acc.slice(0, insertAt) + ch + acc.slice(insertAt); } return acc; } /** * Generate an EFF-style Diceware passphrase. Default is 6 words drawn * uniformly from the EFF Long Wordlist (~77.5 bits of entropy) joined * with `-`. * * @throws PwgenError(PWGEN.INVALID_OPTIONS) on schema mismatch. * @throws PwgenError(PWGEN.WORDLIST_TOO_SHORT) on inline wordlists with * < 256 entries. * @throws PwgenError(PWGEN.WORDLIST_NOT_REGISTERED) for unknown registry * names. */ export function generatePassphrase( opts: GeneratePassphraseOptions = {}, rng?: Rng ): string { const parsed = GeneratePassphraseOptionsSchema.safeParse(opts); if (!parsed.success) { recordError("PWGEN.INVALID_OPTIONS"); throw pwgenError("PWGEN.INVALID_OPTIONS", parsed.error.message, { cause: parsed.error, }); } const o = parsed.data; const length = o.length ?? DEFAULT_LENGTH; const separator = o.separator ?? DEFAULT_SEPARATOR; const capitalize = o.capitalize ?? "none"; const addDigits = o.addDigits ?? 0; const addSymbols = o.addSymbols ?? 0; const effectiveRng = rng ?? defaultRng(); const wordlist = resolveWordlist(o.wordlist); const start = performance.now(); const picked: string[] = []; for (let i = 0; i < length; i++) { picked.push( wordlist[randomInt(effectiveRng, wordlist.length)] as string ); } const cased = applyCapitalization(picked, capitalize, effectiveRng); let joined = cased.join(separator); joined = injectChars(joined, addDigits, DIGITS, effectiveRng); joined = injectChars(joined, addSymbols, SYMBOLS, effectiveRng); const bits = estimatePassphraseEntropy(picked, wordlist.length); recordGeneration("passphrase", performance.now() - start, bits); return joined; }