108 lines
3.8 KiB
TypeScript
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;
|
|
}
|