Files
suaveplan/packages/security-stack/security/password-generator/src/passphrase/passphrase.ts
2026-05-27 23:47:53 +01:00

108 lines
3.8 KiB
TypeScript

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<string>,
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;
}