Initial Commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export { generatePassphrase } from "./passphrase.js";
|
||||
export type {
|
||||
CapitalizationMode,
|
||||
GeneratePassphraseOptions,
|
||||
WordlistRef,
|
||||
} from "./passphrase.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user