Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
export { generatePassphrase } from "./passphrase.js";
export type {
CapitalizationMode,
GeneratePassphraseOptions,
WordlistRef,
} from "./passphrase.types.js";

View File

@@ -0,0 +1,70 @@
# `passphrase`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`generatePassphrase(opts, rng?)` produces an EFF-style Diceware passphrase by drawing `length` words uniformly (via the bias-free `randomInt`) from the wordlist resolved via `opts.wordlist`. The default — 6 words from the EFF Long Wordlist joined with `-` — gives ≈ 77.55 bits of entropy, comfortably above the 75-bit floor recommended for symmetric-key-equivalent secrets. Diceware-style passphrases are dramatically more memorable than random char-class passwords at equivalent entropy, which is why the vault item editor in `@suaveplan/vault` defaults to this mode for the "Generate password" surface.
The function honours configurable separator, three capitalisation modes (`none` / `firstChar` / `randomWord`), and optional `addDigits` / `addSymbols` injections at random positions in the joined output. The decoration knobs exist so the same generator can produce strings that satisfy downstream "must contain a digit" composition rules without abandoning the Diceware backbone.
## Features
- Bundled EFF Long (7776) and EFF Short 1 (1296) wordlists via tree-shakeable subpath imports.
- Caller-supplied `string[]` wordlists allowed past the 256-entry min-size guard.
- Configurable separator (`default "-"`).
- `capitalize: "none" | "firstChar" | "randomWord"`.
- `addDigits` / `addSymbols` injection at randomised positions.
- Deterministic under seeded `Rng`.
- Zod-validated; mismatch → `PWGEN.INVALID_OPTIONS`.
- Emits `pwgen.generate_duration_ms{mode=passphrase}` and `pwgen.entropy_bits{mode=passphrase}` histograms.
## Usage: Basic Example
```ts
import { generatePassphrase } from "@suaveplan/password-generator";
const pp = generatePassphrase({}); // 6 words, EFF Long, joined with "-"
```
## Usage: Advanced Example
```ts
import { generatePassphrase } from "@suaveplan/password-generator";
// 5 short words capitalised at random, two trailing digits, one symbol
const pp = generatePassphrase({
length: 5,
wordlist: "short",
capitalize: "randomWord",
addDigits: 2,
addSymbols: 1,
separator: " ",
});
// Caller-supplied themed wordlist (must be ≥ 256 entries)
const dnd = [/* ... 300 fantasy nouns ... */];
const themed = generatePassphrase({ wordlist: dnd, length: 4 });
```
## API Reference
### `generatePassphrase(opts?: GeneratePassphraseOptions, rng?: Rng): string`
- `opts.length` (default `6`): number of words to draw.
- `opts.wordlist` (default `"long"`): `"long"` / `"short"` / registry name / inline `string[]`.
- `opts.separator` (default `"-"`): string inserted between words before decoration.
- `opts.capitalize` (default `"none"`): `"none"` / `"firstChar"` / `"randomWord"`.
- `opts.addDigits` / `opts.addSymbols` (default `0`): count of characters injected at randomised positions.
- `rng`: defaults to `defaultRng()`.
Throws `PwgenError(PWGEN.INVALID_OPTIONS)` on Zod mismatch, `PwgenError(PWGEN.WORDLIST_TOO_SHORT)` for inline arrays below 256 entries, and `PwgenError(PWGEN.WORDLIST_NOT_REGISTERED)` for unknown registry names.
## Implementation Notes
Wordlist resolution lazily routes through `getWordlist(name)` so the registry's min-size guard applies uniformly — there's no special-case for the bundled EFF lists. Inline `string[]` wordlists bypass the registry but go through the same 256-entry floor inline, so a caller passing a tiny array sees the same `PWGEN.WORDLIST_TOO_SHORT` they would see from `registerWordlist`.
Decoration is applied _after_ joining: `addDigits` / `addSymbols` inserts characters at uniformly-random positions in the already-joined string. This means a digit can land mid-word (e.g. `hor5se-rifle`) — that's intentional because inserting only at word boundaries would create a small-cardinality position distribution that's easier to fingerprint. Per-class injection uses bias-free `randomInt` against the `DIGITS` / `SYMBOLS` sets defined in the `charsets` module.
Entropy reporting uses `estimatePassphraseEntropy(words, wordlistSize)` against the _picked_ words (not the post-decoration string), so the reported figure reflects the Diceware contribution. Decoration adds a small amount of entropy on top of that, but the package does not currently roll that into the reported figure to keep the formula stable across runs.

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import fc from "fast-check";
import { EFF_LONG_WORDLIST } from "../wordlists/eff-long.js";
import { EFF_SHORT_WORDLIST } from "../wordlists/eff-short.js";
import { generatePassphrase } from "./passphrase.js";
function seededRng(seed: number): Rng {
let s = seed >>> 0;
return {
nextUint32(): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
describe("generatePassphrase — spec conformance", () => {
it("every sampled passphrase respects the membership invariant", {
timeout: 30000,
}, () => {
const longSet = new Set(EFF_LONG_WORDLIST);
const shortSet = new Set(EFF_SHORT_WORDLIST);
fc.assert(
fc.property(
fc.record({
ref: fc.constantFrom("long" as const, "short" as const),
length: fc.integer({ min: 1, max: 12 }),
}),
({ ref, length }) => {
// Use space separator so hyphenated entries
// (`yo-yo`, `t-shirt`, …) round-trip through split.
const pp = generatePassphrase({
wordlist: ref,
length,
separator: " ",
});
const words = pp.split(" ");
expect(words.length).toBe(length);
const lookup = ref === "long" ? longSet : shortSet;
for (const w of words) expect(lookup.has(w)).toBe(true);
}
),
{ numRuns: 200 }
);
});
it("every output word is a member of the resolved wordlist", {
timeout: 5000,
}, () => {
const pp = generatePassphrase({
length: 8,
wordlist: "long",
separator: " ",
});
for (const w of pp.split(" ")) {
expect(EFF_LONG_WORDLIST.includes(w)).toBe(true);
}
});
it("generatePassphrase is deterministic under a seeded Rng", {
timeout: 5000,
}, () => {
const a = generatePassphrase(
{ length: 6, capitalize: "firstChar" },
seededRng(99)
);
const b = generatePassphrase(
{ length: 6, capitalize: "firstChar" },
seededRng(99)
);
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { DIGITS, SYMBOLS } from "../charsets/charsets.js";
import { EFF_LONG_WORDLIST } from "../wordlists/eff-long.js";
import { EFF_SHORT_WORDLIST } from "../wordlists/eff-short.js";
import { generatePassphrase } from "./passphrase.js";
function seededRng(seed: number): Rng {
let s = seed >>> 0;
return {
nextUint32(): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
describe("generatePassphrase", () => {
it("defaults to EFF Long with length 6", { timeout: 5000 }, () => {
const pp = generatePassphrase({});
const words = pp.split("-");
expect(words.length).toBe(6);
for (const w of words) expect(EFF_LONG_WORDLIST.includes(w)).toBe(true);
});
it("draws from EFF Short when wordlist is short", { timeout: 5000 }, () => {
const pp = generatePassphrase({ wordlist: "short", length: 7 });
const words = pp.split("-");
expect(words.length).toBe(7);
for (const w of words)
expect(EFF_SHORT_WORDLIST.includes(w)).toBe(true);
});
it("accepts inline wordlist arrays meeting the min-size guard", {
timeout: 5000,
}, () => {
const inline = Array.from({ length: 256 }, (_, i) => `w${i}`);
const pp = generatePassphrase({ wordlist: inline, length: 4 });
const words = pp.split("-");
expect(words.length).toBe(4);
for (const w of words) expect(inline.includes(w)).toBe(true);
});
it("rejects inline wordlist arrays below 256 entries", {
timeout: 5000,
}, () => {
expect(() =>
generatePassphrase({ wordlist: ["one", "two"], length: 2 })
).toThrow(/PWGEN\.WORDLIST_TOO_SHORT/);
});
it("capitalize firstChar applies to every word", { timeout: 5000 }, () => {
const pp = generatePassphrase({ length: 4, capitalize: "firstChar" });
const words = pp.split("-");
for (const w of words) {
expect(w.charAt(0)).toBe(w.charAt(0).toUpperCase());
expect(/^[A-Z]/.test(w)).toBe(true);
}
});
it("capitalize randomWord applies to exactly one word", {
timeout: 5000,
}, () => {
const pp = generatePassphrase(
{ length: 4, capitalize: "randomWord" },
seededRng(11)
);
const words = pp.split("-");
const upperCount = words.filter((w) => /^[A-Z]/.test(w)).length;
expect(upperCount).toBe(1);
});
it("addDigits and addSymbols inject the requested counts", {
timeout: 5000,
}, () => {
// Use a separator outside both DIGITS and SYMBOLS so the injected
// counts are not confounded by the separator characters.
const pp = generatePassphrase({
length: 3,
addDigits: 2,
addSymbols: 1,
separator: " ",
});
const digitCount = [...pp].filter((c) => DIGITS.has(c)).length;
const symbolCount = [...pp].filter((c) => SYMBOLS.has(c)).length;
expect(digitCount).toBe(2);
expect(symbolCount).toBe(1);
});
it("uses a custom separator", { timeout: 5000 }, () => {
const pp = generatePassphrase({ length: 4, separator: "." });
expect(pp.split(".").length).toBe(4);
});
it("is deterministic under seeded Rng", { timeout: 5000 }, () => {
const a = generatePassphrase(
{ length: 6, capitalize: "firstChar" },
seededRng(42)
);
const b = generatePassphrase(
{ length: 6, capitalize: "firstChar" },
seededRng(42)
);
expect(a).toBe(b);
});
it("rejects invalid input with PWGEN.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(() => generatePassphrase({ length: 0 })).toThrow(
/PWGEN\.INVALID_OPTIONS/
);
});
it("resolves wordlist via registry name", { timeout: 5000 }, () => {
const pp = generatePassphrase({ wordlist: "eff-short", length: 4 });
const words = pp.split("-");
for (const w of words)
expect(EFF_SHORT_WORDLIST.includes(w)).toBe(true);
});
it("works with no opts argument", { timeout: 5000 }, () => {
const pp = generatePassphrase();
expect(pp.split("-").length).toBe(6);
});
it("applies capitalize none by default", { timeout: 5000 }, () => {
const pp = generatePassphrase({ length: 4 });
const words = pp.split("-");
for (const w of words) expect(/^[a-z]/.test(w)).toBe(true);
});
});

View File

@@ -0,0 +1,107 @@
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;
}

View File

@@ -0,0 +1,18 @@
export type CapitalizationMode = "none" | "firstChar" | "randomWord";
/**
* Wordlist reference: `"long"` resolves to the bundled EFF Long Wordlist,
* `"short"` resolves to the bundled EFF Short Wordlist 1, any other
* non-empty string resolves to a name in the registry, and a `string[]`
* is passed through the registry's min-size guard inline.
*/
export type WordlistRef = "long" | "short" | string | readonly string[];
export interface GeneratePassphraseOptions {
readonly length?: number;
readonly wordlist?: WordlistRef;
readonly separator?: string;
readonly capitalize?: CapitalizationMode;
readonly addDigits?: number;
readonly addSymbols?: number;
}