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,72 @@
# `charsets`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical character-class constants used by every generator in the package — `LOWERCASE`, `UPPERCASE`, `DIGITS`, `SYMBOLS`, plus the curated `AMBIGUOUS` and `LOOKALIKES` sets that callers subtract to avoid characters confusable in fixed-width fonts. Exposing each class as a frozen `ReadonlySet<string>` lets callers compose custom unions (`new Set([...LOWERCASE, ...DIGITS])`) without depending on any private representation.
The `buildEffectiveCharset(opts)` helper merges the enabled classes and subtracts `opts.exclude` to produce the deduplicated string array a generator can index into via `randomInt`. The four built-in classes are pairwise disjoint by construction, so insertion-order union is enough to deduplicate — adding a `Set`-tracked check would be dead code.
## Features
- Frozen `ReadonlySet<string>` for each class — safe to share across modules.
- `AMBIGUOUS` (`0Ol1I`) and `LOOKALIKES` curated by frequency of human misreads.
- `SYMBOLS` drawn from the OWASP-recommended printable range; excludes shell-quoting hazards (backtick, backslash, whitespace).
- `buildEffectiveCharset(opts)` accepts string / array / `Set` exclusions.
## Usage: Basic Example
```ts
import {
AMBIGUOUS,
buildEffectiveCharset,
} from "@suaveplan/password-generator";
const charset = buildEffectiveCharset({ exclude: AMBIGUOUS });
```
## Usage: Advanced Example
```ts
import {
DIGITS,
LOWERCASE,
UPPERCASE,
buildEffectiveCharset,
} from "@suaveplan/password-generator";
// Hex charset only (digits + a-f) via two passes
const hexLower = [
...buildEffectiveCharset({ uppercase: false, symbols: false }),
].filter((c) => DIGITS.has(c) || "abcdef".includes(c));
// "Vault-friendly" passwords: drop ambiguous + shell-hostile chars
const safe = buildEffectiveCharset({ exclude: "0Ol1I`\\\"' " });
const charset = safe.filter((c) => !UPPERCASE.has(c) || LOWERCASE.has(c.toLowerCase()));
```
## API Reference
### Constants
- `LOWERCASE: ReadonlySet<string>` — `a–z` (26 entries).
- `UPPERCASE: ReadonlySet<string>` — `A–Z` (26 entries).
- `DIGITS: ReadonlySet<string>` — `0–9` (10 entries).
- `SYMBOLS: ReadonlySet<string>` — `!@#$%^&*()-_=+[]{};:,.<>/?` (25 entries).
- `AMBIGUOUS: ReadonlySet<string>` — `0Ol1I`.
- `LOOKALIKES: ReadonlySet<string>` — `0OQ1lI5S2Z8B`.
### `buildEffectiveCharset(opts?: CharsetOptions): string[]`
Builds the deduplicated union of enabled classes minus `opts.exclude`. Returns a fresh array on every call; insertion order is lowercase → uppercase → digits → symbols.
- `lowercase` / `uppercase` / `digits` / `symbols` — each defaults to `true`.
- `exclude` — `string` (per-character), `readonly string[]`, or `ReadonlySet<string>`; defaults to none.
## Implementation Notes
Every set is frozen at module load via `Object.freeze`, which only freezes the `Set` object itself (not the underlying internal storage). The intent is to surface accidental `.add` / `.delete` calls at call sites; consumers who need to add or remove characters must clone the set first.
`SYMBOLS` intentionally omits whitespace, the backslash, double-quote, and backtick. Those characters routinely break shell interpolations and CSV exports; including them would force every caller to filter them out before interpolating the generated password into a deployment script. Callers who want them can compose `new Set([...SYMBOLS, "\\\""])` themselves — the omission is conservative-by-default but trivially reversible.

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AMBIGUOUS,
buildEffectiveCharset,
DIGITS,
LOOKALIKES,
LOWERCASE,
SYMBOLS,
UPPERCASE,
} from "./charsets.js";
describe("character class constants", () => {
it("LOWERCASE contains 26 entries", { timeout: 5000 }, () => {
expect(LOWERCASE.size).toBe(26);
expect(LOWERCASE.has("a")).toBe(true);
expect(LOWERCASE.has("z")).toBe(true);
expect(LOWERCASE.has("A")).toBe(false);
});
it("UPPERCASE / DIGITS / SYMBOLS sizes match expectations", {
timeout: 5000,
}, () => {
expect(UPPERCASE.size).toBe(26);
expect(DIGITS.size).toBe(10);
expect(SYMBOLS.size).toBeGreaterThan(20);
});
it("AMBIGUOUS contains the canonical 0/O/l/1/I set", {
timeout: 5000,
}, () => {
for (const ch of "0Ol1I") expect(AMBIGUOUS.has(ch)).toBe(true);
});
it("LOOKALIKES is a superset of AMBIGUOUS-style digits", {
timeout: 5000,
}, () => {
expect(LOOKALIKES.has("0")).toBe(true);
expect(LOOKALIKES.has("O")).toBe(true);
});
});
describe("buildEffectiveCharset", () => {
it("defaults to the union of all four classes", { timeout: 5000 }, () => {
const cs = buildEffectiveCharset();
expect(cs.length).toBe(
LOWERCASE.size + UPPERCASE.size + DIGITS.size + SYMBOLS.size
);
});
it("omits a class when disabled", { timeout: 5000 }, () => {
const cs = buildEffectiveCharset({ symbols: false });
for (const ch of SYMBOLS) expect(cs.includes(ch)).toBe(false);
});
it("applies a string-form exclusion", { timeout: 5000 }, () => {
const cs = buildEffectiveCharset({ exclude: "abc" });
expect(cs.includes("a")).toBe(false);
expect(cs.includes("b")).toBe(false);
expect(cs.includes("c")).toBe(false);
});
it("applies an array-form exclusion", { timeout: 5000 }, () => {
const cs = buildEffectiveCharset({ exclude: ["x", "y"] });
expect(cs.includes("x")).toBe(false);
expect(cs.includes("y")).toBe(false);
});
it("applies a Set-form exclusion", { timeout: 5000 }, () => {
const cs = buildEffectiveCharset({ exclude: AMBIGUOUS });
for (const ch of AMBIGUOUS) expect(cs.includes(ch)).toBe(false);
});
it("deduplicates the output", { timeout: 5000 }, () => {
const cs = buildEffectiveCharset();
expect(cs.length).toBe(new Set(cs).size);
});
it("returns empty when every class is disabled", { timeout: 5000 }, () => {
const cs = buildEffectiveCharset({
lowercase: false,
uppercase: false,
digits: false,
symbols: false,
});
expect(cs).toEqual([]);
});
it("handles undefined exclude", { timeout: 5000 }, () => {
expect(
buildEffectiveCharset({ exclude: undefined }).length
).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,90 @@
/**
* Character-class constants exposed as `ReadonlySet<string>` so callers
* can author custom unions and intersections without pulling private
* implementation details.
*
* Sets are frozen at module load time. The strings inside are
* intentionally ASCII-only — Unicode case folding is out of scope here
* (see the package proposal "Out of scope" list).
*/
function freezeSet(chars: string): ReadonlySet<string> {
return Object.freeze(new Set(chars.split(""))) as ReadonlySet<string>;
}
export const LOWERCASE: ReadonlySet<string> = freezeSet(
"abcdefghijklmnopqrstuvwxyz"
);
export const UPPERCASE: ReadonlySet<string> = freezeSet(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
);
export const DIGITS: ReadonlySet<string> = freezeSet("0123456789");
/**
* Symbol set drawn from the OWASP-recommended printable-ASCII range.
* Excludes whitespace and the backtick / backslash pair to dodge shell
* quoting hazards on call sites that interpolate generated passwords
* into shell commands.
*/
export const SYMBOLS: ReadonlySet<string> = freezeSet(
"!@#$%^&*()-_=+[]{};:,.<>/?"
);
/** Characters frequently confused for one another in fixed-width fonts. */
export const AMBIGUOUS: ReadonlySet<string> = freezeSet("0Ol1I");
/** Characters often misread as digits or other letters in handwriting. */
export const LOOKALIKES: ReadonlySet<string> = freezeSet("0OQ1lI5S2Z8B");
export interface CharsetOptions {
readonly lowercase?: boolean | undefined;
readonly uppercase?: boolean | undefined;
readonly digits?: boolean | undefined;
readonly symbols?: boolean | undefined;
readonly exclude?:
| ReadonlySet<string>
| readonly string[]
| string
| undefined;
}
function expandExclusions(
exclude: CharsetOptions["exclude"]
): ReadonlySet<string> {
if (!exclude) return new Set();
if (typeof exclude === "string") return new Set(exclude.split(""));
if (Array.isArray(exclude)) return new Set(exclude as readonly string[]);
return exclude as ReadonlySet<string>;
}
/**
* Build the effective charset for a generator call by unioning enabled
* classes and subtracting `exclude`. Returns a deduplicated string array
* (insertion order preserved) so callers can index into it via
* `randomInt`.
*/
export function buildEffectiveCharset(opts: CharsetOptions = {}): string[] {
const {
lowercase = true,
uppercase = true,
digits = true,
symbols = true,
} = opts;
const excluded = expandExclusions(opts.exclude);
const out: string[] = [];
// LOWERCASE / UPPERCASE / DIGITS / SYMBOLS are pairwise disjoint by
// construction so insertion order alone deduplicates the union.
const add = (set: ReadonlySet<string>): void => {
for (const ch of set) {
if (excluded.has(ch)) continue;
out.push(ch);
}
};
if (lowercase) add(LOWERCASE);
if (uppercase) add(UPPERCASE);
if (digits) add(DIGITS);
if (symbols) add(SYMBOLS);
return out;
}

View File

@@ -0,0 +1,10 @@
export type { CharsetOptions } from "./charsets.js";
export {
AMBIGUOUS,
buildEffectiveCharset,
DIGITS,
LOOKALIKES,
LOWERCASE,
SYMBOLS,
UPPERCASE,
} from "./charsets.js";

View File

@@ -0,0 +1,73 @@
# `entropy`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`estimateEntropy(password, opts?)` returns the upper-bound entropy of a candidate password as `log2(charsetSize) * password.length`, where `charsetSize` is the cardinality of the union of every detected built-in class (lowercase / uppercase / digits / symbols) plus any caller-supplied `opts.extraClasses` whose characters appear in the password. `estimatePassphraseEntropy(words, wordlistSize)` returns `log2(wordlistSize) * words.length` for Diceware-style passphrases. `assertStrength(password, minBits, opts?)` consumes `estimateEntropy` and throws `PWGEN.STRENGTH_INSUFFICIENT` when the computed bits are below the floor.
The entropy formulas assume uniform sampling and ignore patterns — `"AAAAAAAA"` is reported as the same entropy as `"hG83mP9k"` if both contain the same charset classes. Pattern-aware scoring (zxcvbn-style) is intentionally out of scope and lives in the separate `@suaveplan/password-strength` package.
## Features
- Detects class membership in a single pass over the password.
- `extraClasses` opt-in lets callers report custom-charset passwords (emoji unions, hex-only PINs).
- `assertStrength` throws on insufficient entropy with a structured error code.
- Zod-validated `EntropyOptions`; mismatch → `PWGEN.INVALID_OPTIONS`.
## Usage: Basic Example
```ts
import { estimateEntropy } from "@suaveplan/password-generator";
const { bits, charsetSize } = estimateEntropy("Abc123Xy");
// charsetSize === 62, bits === log2(62) * 8 ≈ 47.6
```
## Usage: Advanced Example
```ts
import {
assertStrength,
estimateEntropy,
estimatePassphraseEntropy,
} from "@suaveplan/password-generator";
// Server-side guard before persisting a vault item
function persistVaultItem(pw: string): void {
assertStrength(pw, 80); // throws PWGEN.STRENGTH_INSUFFICIENT if < 80 bits
// ... persist
}
// Custom emoji-augmented class
const e = estimateEntropy("emoji👍2025", { extraClasses: ["👍🎉🚀"] });
// Diceware reporting
const words = ["correct", "horse", "battery", "staple", "alpha", "beta"];
const passEntropy = estimatePassphraseEntropy(words, 7776); // ≈ 77.55 bits
```
## API Reference
### `estimateEntropy(password: string, opts?: EntropyOptions): EntropyEstimate`
Returns `{ bits, charsetSize }`. `bits` is `0` when the password is empty.
### `estimatePassphraseEntropy(words: readonly string[], wordlistSize: number): number`
Returns `log2(wordlistSize) * words.length`. Throws `PWGEN.INVALID_OPTIONS` for non-positive integer `wordlistSize`.
### `assertStrength(password: string, minBits: number, opts?: EntropyOptions): void`
Returns `void` on success. Throws `PwgenError(PWGEN.STRENGTH_INSUFFICIENT)` when the computed bits are strictly less than `minBits`.
### `EntropyOptions`
- `extraClasses?: readonly string[]` — additional character classes the password may draw from.
## Implementation Notes
Class detection is a single forward pass over the password, tracking four boolean flags for the built-in classes plus a `Set<number>` of seen `extraClasses` indices. The `charsetSize` accumulator only adds a class's cardinality when the first matching character is seen, which keeps the cost `O(password.length * extraClasses.length)` worst-case.
The formula deliberately reports an _upper bound_. A consumer comparing this number to a cracking-time chart should treat it as a ceiling: real-world passwords contain dictionary patterns, keyboard walks, and date stamps that reduce the effective entropy substantially. Use `@suaveplan/password-strength` for adversarial-aware estimates. `assertStrength` is a deliberately blunt instrument — it answers "does this password meet the floor on the most generous estimator?" — and is intended as a server-side guardrail against drastically weak inputs, not a substitute for proper strength scoring.

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
assertStrength,
estimateEntropy,
estimatePassphraseEntropy,
} from "./entropy.js";
describe("estimateEntropy", () => {
it("reports 26-char charset for lowercase-only password", {
timeout: 5000,
}, () => {
const r = estimateEntropy("abcdefgh");
expect(r.charsetSize).toBe(26);
expect(r.bits).toBeCloseTo(Math.log2(26) * 8, 6);
});
it("reports 62-char charset for lowercase+uppercase+digits", {
timeout: 5000,
}, () => {
const r = estimateEntropy("Abc123Xy");
expect(r.charsetSize).toBe(62);
expect(r.bits).toBeCloseTo(Math.log2(62) * 8, 6);
});
it("adds symbol charset size when symbols present", {
timeout: 5000,
}, () => {
const r = estimateEntropy("Abc!23Xy");
expect(r.charsetSize).toBeGreaterThan(62);
});
it("returns zero charset / zero bits for empty input", {
timeout: 5000,
}, () => {
const r = estimateEntropy("");
expect(r.charsetSize).toBe(0);
expect(r.bits).toBe(0);
});
it("detects extraClasses when characters match", { timeout: 5000 }, () => {
const r = estimateEntropy("emoji👍ab", { extraClasses: ["👍"] });
expect(r.charsetSize).toBeGreaterThanOrEqual(27);
});
it("ignores extraClasses that do not appear", { timeout: 5000 }, () => {
const r = estimateEntropy("abc", { extraClasses: ["XYZ"] });
expect(r.charsetSize).toBe(26);
});
it("rejects malformed opts with PWGEN.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(() =>
estimateEntropy("abc", { extraClasses: 42 as never })
).toThrow(/PWGEN\.INVALID_OPTIONS/);
});
});
describe("estimatePassphraseEntropy", () => {
it("6-word EFF Long passphrase reports ~77.5 bits", {
timeout: 5000,
}, () => {
const e = estimatePassphraseEntropy(
["a", "b", "c", "d", "e", "f"],
7776
);
expect(e).toBeCloseTo(Math.log2(7776) * 6, 6);
});
it("5-word EFF Short passphrase reports ~51.7 bits", {
timeout: 5000,
}, () => {
const e = estimatePassphraseEntropy(["a", "b", "c", "d", "e"], 1296);
expect(e).toBeCloseTo(Math.log2(1296) * 5, 6);
});
it("rejects non-positive wordlistSize", { timeout: 5000 }, () => {
expect(() => estimatePassphraseEntropy(["a"], 0)).toThrow(
/PWGEN\.INVALID_OPTIONS/
);
expect(() => estimatePassphraseEntropy(["a"], 1.5)).toThrow(
/PWGEN\.INVALID_OPTIONS/
);
});
});
describe("assertStrength", () => {
it("assertStrength passes when entropy meets the floor", {
timeout: 5000,
}, () => {
expect(() => assertStrength("Abc123Xy", 40)).not.toThrow();
});
it("assertStrength throws when entropy is below the floor", {
timeout: 5000,
}, () => {
expect(() => assertStrength("aaaaaaaa", 80)).toThrow(
/PWGEN\.STRENGTH_INSUFFICIENT/
);
});
});

View File

@@ -0,0 +1,116 @@
import { DIGITS, LOWERCASE, SYMBOLS, UPPERCASE } from "../charsets/charsets.js";
import { pwgenError } from "../errors/codes.js";
import { recordError } from "../observability/metrics.js";
import { EntropyOptionsSchema } from "../schemas/schemas.js";
export interface EntropyOptions {
/**
* Extra character classes (each a string of unique characters) that
* the password may draw from beyond the four built-in classes. Used
* when the caller authored a custom union via
* {@link buildEffectiveCharset}.
*/
readonly extraClasses?: readonly string[];
}
export interface EntropyEstimate {
readonly bits: number;
readonly charsetSize: number;
}
/**
* Estimate the upper-bound entropy of `password` as
* `log2(charsetSize) * length`.
*
* `charsetSize` is the cardinality of the union of every detected
* built-in class (lowercase / uppercase / digits / symbols) plus any
* `opts.extraClasses` whose characters appear in the password.
*
* This is intentionally a loose upper bound — pattern-aware scoring
* lives in `@suaveplan/password-strength`.
*/
export function estimateEntropy(
password: string,
opts: EntropyOptions = {}
): EntropyEstimate {
const parsed = EntropyOptionsSchema.safeParse(opts);
if (!parsed.success) {
recordError("PWGEN.INVALID_OPTIONS");
throw pwgenError("PWGEN.INVALID_OPTIONS", parsed.error.message, {
cause: parsed.error,
});
}
let charsetSize = 0;
let hasLower = false;
let hasUpper = false;
let hasDigit = false;
let hasSymbol = false;
const seenExtra = new Set<number>();
for (const ch of password) {
if (!hasLower && LOWERCASE.has(ch)) {
hasLower = true;
charsetSize += LOWERCASE.size;
} else if (!hasUpper && UPPERCASE.has(ch)) {
hasUpper = true;
charsetSize += UPPERCASE.size;
} else if (!hasDigit && DIGITS.has(ch)) {
hasDigit = true;
charsetSize += DIGITS.size;
} else if (!hasSymbol && SYMBOLS.has(ch)) {
hasSymbol = true;
charsetSize += SYMBOLS.size;
}
const extras = parsed.data.extraClasses;
if (extras) {
for (let i = 0; i < extras.length; i++) {
if (seenExtra.has(i)) continue;
const cls = extras[i] as string;
if (cls.includes(ch)) {
seenExtra.add(i);
charsetSize += new Set(cls).size;
}
}
}
}
const bits =
charsetSize === 0 ? 0 : Math.log2(charsetSize) * password.length;
return { bits, charsetSize };
}
/**
* Estimate passphrase entropy as `log2(wordlistSize) * words.length`.
* The caller is trusted to pass a `words` array whose members were
* drawn from a wordlist of size `wordlistSize`.
*/
export function estimatePassphraseEntropy(
words: readonly string[],
wordlistSize: number
): number {
if (!Number.isInteger(wordlistSize) || wordlistSize < 1) {
throw pwgenError(
"PWGEN.INVALID_OPTIONS",
`wordlistSize must be a positive integer; received ${wordlistSize}`
);
}
return Math.log2(wordlistSize) * words.length;
}
/**
* Throw `PWGEN.STRENGTH_INSUFFICIENT` when `estimateEntropy(password).bits`
* is strictly less than `minBits`. Returns void on success — callers prove
* strength by absence of throw.
*/
export function assertStrength(
password: string,
minBits: number,
opts?: EntropyOptions
): void {
const { bits } = estimateEntropy(password, opts);
if (bits < minBits) {
recordError("PWGEN.STRENGTH_INSUFFICIENT");
throw pwgenError(
"PWGEN.STRENGTH_INSUFFICIENT",
`password entropy ${bits.toFixed(2)} bits is below the floor of ${minBits} bits`
);
}
}

View File

@@ -0,0 +1,9 @@
export type {
EntropyEstimate,
EntropyOptions,
} from "./entropy.js";
export {
assertStrength,
estimateEntropy,
estimatePassphraseEntropy,
} from "./entropy.js";

View File

@@ -0,0 +1,81 @@
# `errors/codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry for `@suaveplan/password-generator` via `defineCodes("PWGEN", [...])` from `@suaveplan/error`. Every code names exactly one failure class so callers can branch on `error.code` rather than parsing message strings. The companion `PwgenError` class is the single concrete subtype thrown from this package, and `pwgenError(code, message, options)` is a convenience constructor that prefixes the message with the stable code for log scrapers.
The registry is intentionally minimal — six codes, no overlap, every code thrown from at least one production call site (verified by `codes.test.ts`). Adding a code without a thrown call site is banned per `AGENTS.md` §2c.
## Features
- Six stable codes: `INVALID_OPTIONS`, `OPTIONS_INFEASIBLE`, `WORDLIST_TOO_SHORT`, `WORDLIST_NOT_REGISTERED`, `STRENGTH_INSUFFICIENT`, `RNG_FAILED`.
- `PwgenError` extends `SuaveplanError<PwgenErrorCode>` for instanceof-friendly catching.
- `pwgenError(code, message, { cause })` helper that auto-prefixes the message.
- Frozen registry — direct property assignment throws in strict mode.
## Usage: Basic Example
```ts
import { PwgenError, pwgenError } from "@suaveplan/password-generator/errors";
try {
// ...
} catch (e) {
if (e instanceof PwgenError && e.code === "PWGEN.INVALID_OPTIONS") {
// ...
}
}
```
## Usage: Advanced Example
```ts
import {
PWGEN_CODES,
PwgenError,
pwgenError,
} from "@suaveplan/password-generator/errors";
// Catch and re-throw with structural context preserved
try {
throw pwgenError("PWGEN.RNG_FAILED", "downstream RNG bug", {
cause: new Error("entropy source returned non-uint32"),
});
} catch (e) {
if (e instanceof PwgenError) {
console.log(e.code, e.cause);
}
}
// Enumerate every code for an error-mapping table in i18n bundles
for (const code of Object.values(PWGEN_CODES)) {
console.log(code);
}
```
## API Reference
### `PWGEN_CODES`
Frozen record mapping each reason → fully-qualified code string. For example `PWGEN_CODES.RNG_FAILED === "PWGEN.RNG_FAILED"`.
### `PwgenErrorCode`
Literal-typed union of every registered code.
### `class PwgenError extends SuaveplanError<PwgenErrorCode>`
Construct with `(code, message, options?)`. Options may carry `cause: Error`.
### `pwgenError(code, message, options?): PwgenError`
Convenience constructor that prefixes the message with the code string. Non-`Error` `cause` values are silently dropped (per `SuaveplanError` contract).
## Implementation Notes
The registry shape is dictated by `defineCodes` from `@suaveplan/error` — the return type is `{ readonly [K in Reasons[number]]:`PWGEN.${K}`}`, so TypeScript narrows `PWGEN_CODES.INVALID_OPTIONS` to the literal string `"PWGEN.INVALID_OPTIONS"`. This is what makes `if (e.code === "PWGEN.INVALID_OPTIONS")` typecheck without a `string` widening.
Three codes (`OPTIONS_INFEASIBLE`, `WORDLIST_TOO_SHORT`, `STRENGTH_INSUFFICIENT`) are sufficient to express every failure callers care about at first glance; the remaining three (`INVALID_OPTIONS`, `WORDLIST_NOT_REGISTERED`, `RNG_FAILED`) cover bug-shaped errors that should surface during development and never reach production. Catching `PwgenError` and branching on `code` lets call sites distinguish "user typed an invalid option" (recoverable, surface a form-validation message) from "RNG is broken" (panic, log, escalate).

View File

@@ -0,0 +1,79 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PWGEN_CODES, PwgenError, pwgenError } from "./codes.js";
const SRC_ROOT = join(__dirname, "..");
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist")
continue;
walk(p, out);
} else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".bench.ts")
) {
out.push(p);
}
}
return out;
}
describe("PWGEN_CODES registry", () => {
it("every registered code is thrown by at least one production call site", {
timeout: 5000,
}, () => {
const sources = walk(SRC_ROOT)
.filter((p) => !p.endsWith("/errors/codes.ts"))
.map((p) => readFileSync(p, "utf8"))
.join("\n");
for (const code of Object.values(PWGEN_CODES)) {
const short = code.replace(/^PWGEN\./, "");
const pattern = new RegExp(`["']PWGEN\\.${short}["']`);
expect(pattern.test(sources)).toBe(true);
}
});
it("PwgenError is a SuaveplanError subclass with the registered code", {
timeout: 5000,
}, () => {
const e = pwgenError("PWGEN.INVALID_OPTIONS", "test");
expect(e).toBeInstanceOf(PwgenError);
expect(e.code).toBe("PWGEN.INVALID_OPTIONS");
expect(e.message).toContain("PWGEN.INVALID_OPTIONS");
});
it("pwgenError attaches Error cause when supplied", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const e = pwgenError("PWGEN.RNG_FAILED", "outer", { cause });
expect(e.cause).toBe(cause);
});
it("pwgenError ignores non-Error cause", { timeout: 5000 }, () => {
const e = pwgenError("PWGEN.RNG_FAILED", "outer", { cause: "string" });
expect(e.cause).toBeUndefined();
});
it("PwgenError ctor accepts default options", { timeout: 5000 }, () => {
const e = new PwgenError("PWGEN.RNG_FAILED", "msg");
expect(e.code).toBe("PWGEN.RNG_FAILED");
});
it("pwgenError accepts default options", { timeout: 5000 }, () => {
const e = pwgenError("PWGEN.RNG_FAILED", "msg");
expect(e.code).toBe("PWGEN.RNG_FAILED");
});
it("src tree exists and contains the expected modules", {
timeout: 5000,
}, () => {
expect(existsSync(join(SRC_ROOT, "random"))).toBe(true);
expect(statSync(join(SRC_ROOT, "random")).isDirectory()).toBe(true);
});
});

View File

@@ -0,0 +1,47 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable, registered error codes for `@suaveplan/password-generator`.
*
* Each code names a single failure class so callers can branch on the root
* cause rather than parse message strings. Codes are namespaced `PWGEN.*`
* and every entry is exercised by at least one production call site (no
* orphan registry entries — see `openspec/AGENTS.md` §2c).
*/
export const PWGEN_CODES = defineCodes("PWGEN", [
"INVALID_OPTIONS",
"OPTIONS_INFEASIBLE",
"WORDLIST_TOO_SHORT",
"WORDLIST_NOT_REGISTERED",
"STRENGTH_INSUFFICIENT",
"RNG_FAILED",
] as const);
/** Literal-typed union of every registered code. */
export type PwgenErrorCode = (typeof PWGEN_CODES)[keyof typeof PWGEN_CODES];
/** Base class for every error thrown by `@suaveplan/password-generator`. */
export class PwgenError extends SuaveplanError<PwgenErrorCode> {
constructor(
code: PwgenErrorCode,
message: string,
options: { cause?: Error } = {}
) {
super(code, message, options);
}
}
/**
* Helper that constructs a `PwgenError` carrying a stable code and an
* optional underlying cause. Centralised so call sites stay terse and the
* code prefix appears in `.message` for log scrapers and toThrow matchers.
*/
export function pwgenError(
code: PwgenErrorCode,
message: string,
options: { cause?: unknown } = {}
): PwgenError {
const opts: { cause?: Error } = {};
if (options.cause instanceof Error) opts.cause = options.cause;
return new PwgenError(code, `${code}: ${message}`, opts);
}

View File

@@ -0,0 +1,6 @@
export {
PWGEN_CODES,
PwgenError,
type PwgenErrorCode,
pwgenError,
} from "./codes.js";

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
describe("root barrel surface", () => {
it("exports the documented public API", { timeout: 5000 }, () => {
expect(typeof root.generatePassword).toBe("function");
expect(typeof root.generatePassphrase).toBe("function");
expect(typeof root.generatePronounceablePassword).toBe("function");
expect(typeof root.generatePin).toBe("function");
expect(typeof root.estimateEntropy).toBe("function");
expect(typeof root.estimatePassphraseEntropy).toBe("function");
expect(typeof root.assertStrength).toBe("function");
expect(typeof root.randomInt).toBe("function");
expect(typeof root.shuffleInPlace).toBe("function");
expect(typeof root.defaultRng).toBe("function");
expect(typeof root.registerWordlist).toBe("function");
expect(typeof root.getWordlist).toBe("function");
expect(typeof root.listWordlists).toBe("function");
expect(root.EFF_LONG_WORDLIST.length).toBe(7776);
expect(root.EFF_SHORT_WORDLIST.length).toBe(1296);
expect(root.PWGEN_CODES.RNG_FAILED).toBe("PWGEN.RNG_FAILED");
expect(typeof root.GeneratePasswordOptionsSchema).toBe("object");
});
});

View File

@@ -0,0 +1,68 @@
// Public root barrel for `@suaveplan/password-generator`. Explicit named
// re-exports per CLAUDE.md §10.
export {
AMBIGUOUS,
buildEffectiveCharset,
type CharsetOptions,
DIGITS,
LOOKALIKES,
LOWERCASE,
SYMBOLS,
UPPERCASE,
} from "./charsets/charsets.js";
export {
assertStrength,
type EntropyEstimate,
type EntropyOptions,
estimateEntropy,
estimatePassphraseEntropy,
} from "./entropy/entropy.js";
export {
PWGEN_CODES,
PwgenError,
type PwgenErrorCode,
pwgenError,
} from "./errors/codes.js";
export {
entropyBits,
errorsTotal,
type GenerateMode,
generateDurationMs,
recordError,
recordGeneration,
} from "./observability/metrics.js";
export { generatePassphrase } from "./passphrase/passphrase.js";
export type {
CapitalizationMode,
GeneratePassphraseOptions,
WordlistRef,
} from "./passphrase/passphrase.types.js";
export { generatePassword } from "./password/password.js";
export type {
GeneratePasswordOptions,
PasswordMode,
RequiredClasses,
} from "./password/password.types.js";
export {
generatePronounceablePassword,
PRONOUNCEABLE_CONSONANTS,
PRONOUNCEABLE_VOWELS,
} from "./password/pronounceable.js";
export { generatePin } from "./pin/pin.js";
export { defaultRng } from "./random/default-rng.js";
export { shuffleInPlace } from "./random/fisher-yates.js";
export { randomInt } from "./random/random-int.js";
export {
EntropyOptionsSchema,
GeneratePassphraseOptionsSchema,
GeneratePasswordOptionsSchema,
} from "./schemas/schemas.js";
export { EFF_LONG_WORDLIST } from "./wordlists/eff-long.js";
export { EFF_SHORT_WORDLIST } from "./wordlists/eff-short.js";
export {
getWordlist,
listWordlists,
MIN_WORDLIST_SIZE,
registerWordlist,
} from "./wordlists/registry.js";

View File

@@ -0,0 +1,9 @@
export {
_resetInstruments,
entropyBits,
errorsTotal,
type GenerateMode,
generateDurationMs,
recordError,
recordGeneration,
} from "./metrics.js";

View File

@@ -0,0 +1,75 @@
# `observability/metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wires the package's OpenTelemetry instruments — `pwgen.generate_duration_ms{mode}` and `pwgen.entropy_bits{mode}` histograms, plus a `pwgen.errors_total{code}` counter — and exposes thin `recordGeneration` / `recordError` helpers every production call site uses. Instruments are obtained from `metrics.getMeter("@suaveplan/password-generator")` and lazy-initialised on first use so the active `MeterProvider` is always honoured (a global provider installed at startup overrides the package's no-op default).
The `mode` attribute is bounded to four documented values — `"random"`, `"pronounceable"`, `"passphrase"`, `"pin"` — so cardinality on every dashboard stays flat regardless of call-site volume. The `code` attribute on the counter mirrors the stable `PWGEN.*` registry, so a single counter view shows the full failure-mode mix.
## Features
- Lazy-init pattern with `_resetInstruments()` test hook.
- Three instruments: two histograms (`generate_duration_ms`, `entropy_bits`) + one counter (`errors_total`).
- Low-cardinality `mode` and `code` attributes.
- `recordGeneration(mode, durationMs, bits)` rolls duration + entropy into one call.
- `recordError(code)` accepts the raw `PWGEN.*` code string.
## Usage: Basic Example
```ts
import { recordError, recordGeneration } from "@suaveplan/password-generator";
const start = performance.now();
const pw = "abc...";
recordGeneration("random", performance.now() - start, 95.2);
```
## Usage: Advanced Example
```ts
import { metrics } from "@opentelemetry/api";
import { _resetInstruments } from "@suaveplan/password-generator";
// Install an in-memory provider for tests, then exercise the package.
const calls: { name: string; value: number; attrs: Record<string, string> }[] = [];
metrics.setGlobalMeterProvider({
getMeter: () => ({
createCounter: (name: string) => ({
add: (v: number, attrs: Record<string, string> = {}) =>
calls.push({ name, value: v, attrs }),
}),
createHistogram: (name: string) => ({
record: (v: number, attrs: Record<string, string> = {}) =>
calls.push({ name, value: v, attrs }),
}),
}),
} as never);
_resetInstruments(); // Force lazy-init to use the fake provider next call
```
## API Reference
### `recordGeneration(mode: GenerateMode, durationMs: number, bits: number): void`
Records the per-call duration on `pwgen.generate_duration_ms` and the entropy on `pwgen.entropy_bits`, both tagged with `mode`.
### `recordError(code: string): void`
Increments `pwgen.errors_total` by 1 with `{ code }`.
### `_resetInstruments(): void`
Test-only hook that clears the lazy instrument cache. Call before installing a fake provider in tests.
### `generateDurationMs` / `entropyBits` / `errorsTotal`
Public facades on the underlying instruments — call `.record(value, { mode })` or `.add(value, { code })` for direct emission.
## Implementation Notes
Lazy initialisation is critical: if instruments were created at module load, they would bind to whatever `MeterProvider` was global at that moment (almost always the no-op default), and a test installing a real provider afterwards would see no data. The `??`-style lazy pattern means every call to `getInstruments` walks the active provider's `getMeter` lookup until the first non-no-op call is made; from that point the cached references are reused for the lifetime of the process (or until `_resetInstruments()` clears them).
The four-value `mode` enum keeps per-mode cardinality bounded; adding a fifth mode would require a deliberate edit here and a refresh of dashboards. Resist the temptation to thread `password.length` or per-character `class` attributes into the histogram — those would explode cardinality and break the cost model OpenTelemetry backends assume.

View File

@@ -0,0 +1,149 @@
import { metrics } from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { generatePassphrase } from "../passphrase/passphrase.js";
import { generatePassword } from "../password/password.js";
import { generatePronounceablePassword } from "../password/pronounceable.js";
import { generatePin } from "../pin/pin.js";
import {
_resetInstruments,
entropyBits,
errorsTotal,
generateDurationMs,
recordError,
recordGeneration,
} from "./metrics.js";
interface CounterCall {
name: string;
value: number;
attrs: Record<string, string>;
}
interface HistogramCall {
name: string;
value: number;
attrs: Record<string, string>;
}
let counterCalls: CounterCall[];
let histogramCalls: HistogramCall[];
function installFakeProvider(): void {
counterCalls = [];
histogramCalls = [];
const provider = {
getMeter: () => ({
createCounter: (name: string) => ({
add: (v: number, attrs: Record<string, string> = {}) => {
counterCalls.push({ name, value: v, attrs });
},
}),
createHistogram: (name: string) => ({
record: (v: number, attrs: Record<string, string> = {}) => {
histogramCalls.push({ name, value: v, attrs });
},
}),
createUpDownCounter: () => ({ add: () => {} }),
createObservableCounter: () => ({
addCallback: () => {},
removeCallback: () => {},
}),
createObservableGauge: () => ({
addCallback: () => {},
removeCallback: () => {},
}),
createObservableUpDownCounter: () => ({
addCallback: () => {},
removeCallback: () => {},
}),
createGauge: () => ({ record: () => {} }),
}),
};
metrics.setGlobalMeterProvider(
provider as unknown as Parameters<
typeof metrics.setGlobalMeterProvider
>[0]
);
}
describe("OpenTelemetry instrumentation", () => {
beforeEach(() => {
_resetInstruments();
installFakeProvider();
});
afterEach(() => {
metrics.disable();
_resetInstruments();
});
it("emits pwgen.generate_duration_ms histogram for random mode", {
timeout: 5000,
}, () => {
generatePassword({ length: 16 });
const hit = histogramCalls.find(
(h) =>
h.name === "pwgen.generate_duration_ms" &&
h.attrs.mode === "random"
);
expect(hit).toBeDefined();
});
it("emits pwgen.entropy_bits histogram for passphrase mode", {
timeout: 5000,
}, () => {
generatePassphrase({ length: 4 });
const hit = histogramCalls.find(
(h) =>
h.name === "pwgen.entropy_bits" && h.attrs.mode === "passphrase"
);
expect(hit).toBeDefined();
});
it("emits histogram for pronounceable mode", { timeout: 5000 }, () => {
generatePronounceablePassword({ length: 9, mode: "pronounceable" });
const hit = histogramCalls.find(
(h) =>
h.name === "pwgen.generate_duration_ms" &&
h.attrs.mode === "pronounceable"
);
expect(hit).toBeDefined();
});
it("emits histogram for pin mode", { timeout: 5000 }, () => {
generatePin(6);
const hit = histogramCalls.find(
(h) =>
h.name === "pwgen.generate_duration_ms" &&
h.attrs.mode === "pin"
);
expect(hit).toBeDefined();
});
it("increments pwgen.errors_total on PWGEN.OPTIONS_INFEASIBLE", {
timeout: 5000,
}, () => {
expect(() => generatePassword({ length: 0 })).toThrow();
const hit = counterCalls.find(
(c) => c.name === "pwgen.errors_total" && c.attrs.code !== undefined
);
expect(hit).toBeDefined();
});
it("recordGeneration / recordError / instrument facades route through the active provider", {
timeout: 5000,
}, () => {
recordGeneration("random", 1, 64);
recordError("PWGEN.RNG_FAILED");
generateDurationMs.record(2, { mode: "passphrase" });
entropyBits.record(50, { mode: "passphrase" });
errorsTotal.add(1, { code: "PWGEN.INVALID_OPTIONS" });
expect(histogramCalls.length).toBeGreaterThan(0);
expect(counterCalls.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,88 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/** Low-cardinality `mode` enum for the histograms below. */
export type GenerateMode = "random" | "pronounceable" | "passphrase" | "pin";
let _generateDurationMs: Histogram | undefined;
let _entropyBits: Histogram | undefined;
let _errorsTotal: Counter | undefined;
function getInstruments(): {
generateDurationMs: Histogram;
entropyBits: Histogram;
errorsTotal: Counter;
} {
const meter = metrics.getMeter("@suaveplan/password-generator");
_generateDurationMs ??= meter.createHistogram(
"pwgen.generate_duration_ms",
{
description:
"Wall-clock duration of a single successful password / passphrase / PIN generation",
unit: "ms",
}
);
_entropyBits ??= meter.createHistogram("pwgen.entropy_bits", {
description: "Estimated entropy of the generated secret",
unit: "bit",
});
_errorsTotal ??= meter.createCounter("pwgen.errors_total", {
description:
"Count of typed errors thrown from @suaveplan/password-generator",
});
return {
generateDurationMs: _generateDurationMs,
entropyBits: _entropyBits,
errorsTotal: _errorsTotal,
};
}
/**
* Record a successful generation. Threading every production path through
* a single helper keeps the attribute key set ("mode") consistent and the
* cardinality bounded.
*/
export function recordGeneration(
mode: GenerateMode,
durationMs: number,
bits: number
): void {
const { generateDurationMs, entropyBits } = getInstruments();
generateDurationMs.record(durationMs, { mode });
entropyBits.record(bits, { mode });
}
/** Record a typed error by stable code. */
export function recordError(code: string): void {
const { errorsTotal } = getInstruments();
errorsTotal.add(1, { code });
}
/** Reset the cached instruments (test-only). */
export function _resetInstruments(): void {
_generateDurationMs = undefined;
_entropyBits = undefined;
_errorsTotal = undefined;
}
/**
* Public-facing accessors so callers (and tests) can inspect the
* underlying instruments. Lazy-evaluated so the active MeterProvider is
* always honoured.
*/
export const generateDurationMs = {
record(value: number, attrs: { mode: GenerateMode }): void {
getInstruments().generateDurationMs.record(value, attrs);
},
};
export const entropyBits = {
record(value: number, attrs: { mode: GenerateMode }): void {
getInstruments().entropyBits.record(value, attrs);
},
};
export const errorsTotal = {
add(value: number, attrs: { code: string }): void {
getInstruments().errorsTotal.add(value, attrs);
},
};

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

View File

@@ -0,0 +1,11 @@
export { generatePassword } from "./password.js";
export type {
GeneratePasswordOptions,
PasswordMode,
RequiredClasses,
} from "./password.types.js";
export {
generatePronounceablePassword,
PRONOUNCEABLE_CONSONANTS,
PRONOUNCEABLE_VOWELS,
} from "./pronounceable.js";

View File

@@ -0,0 +1,97 @@
# `password`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - pronounceable — `generatePronounceablePassword(opts…
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
- pronounceable — `generatePronounceablePassword(opts…
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
- pronounceable — `generatePronounceablePassword(opts…
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
- pronounceable — `generatePronounceablePassword(opts…
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
## Purpose
`generatePassword(opts, rng?)` produces a `length`-character password drawn uniformly from the union of enabled character classes minus any caller-supplied exclusions. Required-class quotas (`requireLowercase`, `requireUppercase`, `requireDigits`, `requireSymbols`) are satisfied without leaking position information: one randomly-chosen character from each required class is placed first, the remainder filled from the union, and the entire buffer Fisher-Yates-shuffled so the required-class characters are not biased to the head of the string. NIST SP 800-63B §5.1.1.2 deprecates required-class composition rules — they reduce effective entropy and trade resistance to cracking for a stronger memorability constraint — but downstream sites still mandate them, so the generator supports per-class quotas while documenting the cost.
## Features
- Bias-free draws via `randomInt` (Lemire 2018 rejection sampling).
- Per-class enablement (`lowercase`/`uppercase`/`digits`/`symbols`) and per-class required quotas.
- Arbitrary `exclude` set (string / `string[]` / `Set<string>`).
- Deterministic under a seeded `Rng` for replay tests.
- Zod-validated `opts`; mismatch → `PWGEN.INVALID_OPTIONS`.
- Rejects infeasible options (`length < requiredCount` or empty effective charset) with `PWGEN.OPTIONS_INFEASIBLE`.
- Emits `pwgen.generate_duration_ms{mode=random}` and `pwgen.entropy_bits{mode=random}` on success.
## Usage: Basic Example
```ts
import { generatePassword } from "@suaveplan/password-generator";
const pw = generatePassword({ length: 16 });
```
## Usage: Advanced Example
```ts
import {
AMBIGUOUS,
generatePassword,
} from "@suaveplan/password-generator";
// 32-char password that satisfies every class and avoids confusable chars
const pw = generatePassword({
length: 32,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
exclude: AMBIGUOUS,
});
// Deterministic replay under a seeded Rng
import type { Rng } from "@suaveplan/types";
function seeded(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;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
const replayed = generatePassword({ length: 24 }, seeded(42));
```
## API Reference
### `generatePassword(opts: GeneratePasswordOptions, rng?: Rng): string`
- `opts.length`: positive integer total length.
- `opts.lowercase` / `opts.uppercase` / `opts.digits` / `opts.symbols`: defaults to `true`; pass `false` to omit the class from the union.
- `opts.requireLowercase` / `opts.requireUppercase` / `opts.requireDigits` / `opts.requireSymbols`: defaults to `false`; when `true`, at least one character from the class is guaranteed in the output.
- `opts.exclude`: characters to subtract from the union after class merge.
- `opts.mode`: ignored by `generatePassword`; see `pronounceable.ts` for the `pronounceable` mode.
- `rng`: defaults to `defaultRng()`.
Throws `PwgenError(PWGEN.INVALID_OPTIONS)` on Zod mismatch, `PwgenError(PWGEN.OPTIONS_INFEASIBLE)` when `length < requiredCount` or when the effective charset is empty after exclusions.
## Implementation Notes
The required-class placement is a two-phase fill: phase 1 pushes one randomly-chosen character per enabled required class into the buffer; phase 2 fills the remainder from the full effective charset. The buffer is then shuffled in-place via `shuffleInPlace` so the required-class characters end up at uniformly-distributed positions across `[0, length)`. Without the shuffle, the leading `requiredCount` characters would be deterministic-by-position, which is observable to an attacker and weakens the resistance of the generated password.
Effective-charset construction goes through `buildEffectiveCharset`, which performs class-merge + exclusion arithmetic. If any required class becomes empty after exclusions (e.g. `requireDigits: true` with `exclude: "0123456789"`) the generator throws `PWGEN.OPTIONS_INFEASIBLE` instead of producing an output that silently violates the contract. Entropy is reported via `estimateEntropy` immediately before recording — this is an upper-bound `log2(charset) * length` estimate; pattern-aware scoring lives in `@suaveplan/password-strength`.

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import fc from "fast-check";
import { DIGITS, LOWERCASE, SYMBOLS, UPPERCASE } from "../charsets/charsets.js";
import { generatePassword } from "./password.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("generatePassword — spec conformance", () => {
it("every sampled options bundle yields a quota-respecting password", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.record({
length: fc.integer({ min: 4, max: 32 }),
rl: fc.boolean(),
ru: fc.boolean(),
rd: fc.boolean(),
rs: fc.boolean(),
}),
({ length, rl, ru, rd, rs }) => {
const required =
(rl ? 1 : 0) +
(ru ? 1 : 0) +
(rd ? 1 : 0) +
(rs ? 1 : 0);
if (length < required) return;
const pw = generatePassword({
length,
requireLowercase: rl,
requireUppercase: ru,
requireDigits: rd,
requireSymbols: rs,
});
if (rl)
expect([...pw].some((c) => LOWERCASE.has(c))).toBe(
true
);
if (ru)
expect([...pw].some((c) => UPPERCASE.has(c))).toBe(
true
);
if (rd)
expect([...pw].some((c) => DIGITS.has(c))).toBe(true);
if (rs)
expect([...pw].some((c) => SYMBOLS.has(c))).toBe(true);
}
),
{ numRuns: 200 }
);
});
it("required-class character position is uniform after shuffle", {
timeout: 30000,
}, () => {
const trials = 2000;
const length = 10;
const positionCounts = new Array<number>(length).fill(0);
for (let i = 0; i < trials; i++) {
const pw = generatePassword({
length,
requireDigits: true,
lowercase: true,
uppercase: false,
digits: false,
symbols: false,
});
for (let j = 0; j < pw.length; j++) {
if (DIGITS.has(pw[j] as string)) {
positionCounts[j] = (positionCounts[j] as number) + 1;
break;
}
}
}
// With one required digit shuffled across `length` positions, the
// first-digit position is uniform. Expected per bucket: trials/length.
// Allow ±35% slack to keep this test stable on CI variance.
const expected = trials / length;
for (const count of positionCounts) {
expect(count).toBeGreaterThan(expected * 0.65);
expect(count).toBeLessThan(expected * 1.35);
}
});
it("every required class is present in the output", {
timeout: 30000,
}, () => {
for (let trial = 0; trial < 100; trial++) {
const pw = generatePassword({
length: 16,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
});
expect([...pw].some((c) => LOWERCASE.has(c))).toBe(true);
expect([...pw].some((c) => UPPERCASE.has(c))).toBe(true);
expect([...pw].some((c) => DIGITS.has(c))).toBe(true);
expect([...pw].some((c) => SYMBOLS.has(c))).toBe(true);
}
});
it("generatePassword is deterministic under a seeded Rng", {
timeout: 5000,
}, () => {
const a = generatePassword(
{ length: 32, requireDigits: true },
seededRng(123)
);
const b = generatePassword(
{ length: 32, requireDigits: true },
seededRng(123)
);
expect(a).toBe(b);
});
it("default Rng draws from crypto-utils.randomBytes", {
timeout: 5000,
}, async () => {
const module = await import("@suaveplan/crypto-utils");
expect(typeof module.randomBytes).toBe("function");
// Sanity: the generated output is a 16-char string.
const pw = generatePassword({ length: 16 });
expect(pw.length).toBe(16);
});
});

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import {
AMBIGUOUS,
DIGITS,
LOWERCASE,
SYMBOLS,
UPPERCASE,
} from "../charsets/charsets.js";
import { generatePassword } from "./password.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("generatePassword", () => {
it("generates a length-16 password from the default union", {
timeout: 5000,
}, () => {
const pw = generatePassword({ length: 16 });
expect(pw.length).toBe(16);
for (const ch of pw) {
const inUnion =
LOWERCASE.has(ch) ||
UPPERCASE.has(ch) ||
DIGITS.has(ch) ||
SYMBOLS.has(ch);
expect(inUnion).toBe(true);
}
});
it("respects exclusion sets", { timeout: 5000 }, () => {
const pw = generatePassword({ length: 32, exclude: AMBIGUOUS });
for (const ch of pw) expect(AMBIGUOUS.has(ch)).toBe(false);
});
it("satisfies all required-class quotas simultaneously", {
timeout: 5000,
}, () => {
for (let trial = 0; trial < 50; trial++) {
const pw = generatePassword({
length: 12,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
});
expect([...pw].some((c) => LOWERCASE.has(c))).toBe(true);
expect([...pw].some((c) => UPPERCASE.has(c))).toBe(true);
expect([...pw].some((c) => DIGITS.has(c))).toBe(true);
expect([...pw].some((c) => SYMBOLS.has(c))).toBe(true);
}
});
it("throws PWGEN.OPTIONS_INFEASIBLE when length below required count", {
timeout: 5000,
}, () => {
expect(() =>
generatePassword({
length: 2,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
})
).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
});
it("throws PWGEN.OPTIONS_INFEASIBLE when effective charset is empty", {
timeout: 5000,
}, () => {
expect(() =>
generatePassword({
length: 8,
lowercase: false,
uppercase: false,
digits: false,
symbols: false,
})
).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
});
it("throws PWGEN.OPTIONS_INFEASIBLE when a required class is excluded out", {
timeout: 5000,
}, () => {
expect(() =>
generatePassword({
length: 8,
requireDigits: true,
exclude: "0123456789",
})
).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
});
it("is deterministic under a seeded Rng", { timeout: 5000 }, () => {
const a = generatePassword(
{ length: 32, requireDigits: true },
seededRng(7)
);
const b = generatePassword(
{ length: 32, requireDigits: true },
seededRng(7)
);
expect(a).toBe(b);
});
it("only requires lowercase when only lowercase requested", {
timeout: 5000,
}, () => {
const pw = generatePassword({ length: 8, requireLowercase: true });
expect([...pw].some((c) => LOWERCASE.has(c))).toBe(true);
});
it("only requires uppercase when only uppercase requested", {
timeout: 5000,
}, () => {
const pw = generatePassword({ length: 8, requireUppercase: true });
expect([...pw].some((c) => UPPERCASE.has(c))).toBe(true);
});
it("only requires symbols when only symbols requested", {
timeout: 5000,
}, () => {
const pw = generatePassword({ length: 8, requireSymbols: true });
expect([...pw].some((c) => SYMBOLS.has(c))).toBe(true);
});
it("rejects invalid schema input with PWGEN.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(() => generatePassword({ length: -1 })).toThrow(
/PWGEN\.INVALID_OPTIONS/
);
});
it("accepts array-form exclude in generatePassword", {
timeout: 5000,
}, () => {
const pw = generatePassword({
length: 16,
requireDigits: true,
exclude: ["a", "b", "c"],
});
for (const ch of pw) {
expect(ch === "a" || ch === "b" || ch === "c").toBe(false);
}
});
it("accepts Set-form exclude in generatePassword", {
timeout: 5000,
}, () => {
const setEx = new Set(["a", "b"]);
const pw = generatePassword({ length: 16, exclude: setEx });
for (const ch of pw) {
expect(setEx.has(ch)).toBe(false);
}
});
});

View File

@@ -0,0 +1,109 @@
import type { Rng } from "@suaveplan/types";
import {
buildEffectiveCharset,
DIGITS,
LOWERCASE,
SYMBOLS,
UPPERCASE,
} from "../charsets/charsets.js";
import { estimateEntropy } 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 { shuffleInPlace } from "../random/fisher-yates.js";
import { randomInt } from "../random/random-int.js";
import { GeneratePasswordOptionsSchema } from "../schemas/schemas.js";
import type { GeneratePasswordOptions } from "./password.types.js";
function requiredCharFor(
klass: "lower" | "upper" | "digit" | "symbol",
exclude: ReadonlySet<string>,
rng: Rng
): string {
const map: Record<typeof klass, ReadonlySet<string>> = {
lower: LOWERCASE,
upper: UPPERCASE,
digit: DIGITS,
symbol: SYMBOLS,
};
const filtered = [...map[klass]].filter((c) => !exclude.has(c));
if (filtered.length === 0) {
throw pwgenError(
"PWGEN.OPTIONS_INFEASIBLE",
`required class "${klass}" became empty after applying exclusions`
);
}
return filtered[randomInt(rng, filtered.length)] as string;
}
function expandExclusion(
exclude: GeneratePasswordOptions["exclude"]
): ReadonlySet<string> {
if (!exclude) return new Set<string>();
if (typeof exclude === "string") return new Set(exclude.split(""));
if (Array.isArray(exclude)) return new Set(exclude as readonly string[]);
return exclude as ReadonlySet<string>;
}
/**
* Generate a `length`-character password drawn uniformly from the union of
* enabled character classes minus any caller-supplied exclusions.
*
* Required-class quotas (`requireLowercase`/`requireUppercase`/
* `requireDigits`/`requireSymbols`) are satisfied by placing one randomly-
* chosen character from each required class first, filling the remainder
* from the union, and Fisher-Yates-shuffling the buffer so the required-
* class characters are not biased to the head of the string.
*
* @throws PwgenError(PWGEN.INVALID_OPTIONS) on schema mismatch.
* @throws PwgenError(PWGEN.OPTIONS_INFEASIBLE) if `length < requiredCount`
* or if the effective charset is empty after exclusions.
*/
export function generatePassword(
opts: GeneratePasswordOptions,
rng?: Rng
): string {
const parsed = GeneratePasswordOptionsSchema.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 required: ("lower" | "upper" | "digit" | "symbol")[] = [];
if (o.requireLowercase) required.push("lower");
if (o.requireUppercase) required.push("upper");
if (o.requireDigits) required.push("digit");
if (o.requireSymbols) required.push("symbol");
if (o.length < required.length) {
recordError("PWGEN.OPTIONS_INFEASIBLE");
throw pwgenError(
"PWGEN.OPTIONS_INFEASIBLE",
`length ${o.length} is below required-class count ${required.length}`
);
}
const effectiveRng = rng ?? defaultRng();
const charset = buildEffectiveCharset(o);
if (charset.length === 0) {
recordError("PWGEN.OPTIONS_INFEASIBLE");
throw pwgenError(
"PWGEN.OPTIONS_INFEASIBLE",
"effective charset is empty — at least one class must be enabled and not fully excluded"
);
}
const exclude = expandExclusion(o.exclude);
const buffer: string[] = [];
for (const klass of required) {
buffer.push(requiredCharFor(klass, exclude, effectiveRng));
}
while (buffer.length < o.length) {
buffer.push(charset[randomInt(effectiveRng, charset.length)] as string);
}
shuffleInPlace(buffer, effectiveRng);
const out = buffer.join("");
const start = performance.now();
const { bits } = estimateEntropy(out);
recordGeneration("random", performance.now() - start, bits);
return out;
}

View File

@@ -0,0 +1,25 @@
import type { CharsetOptions } from "../charsets/charsets.js";
/** Generation mode discriminator. */
export type PasswordMode = "random" | "pronounceable";
/** Required-class quotas — caller asserts the result must contain ≥ 1 char from each enabled class. */
export interface RequiredClasses {
readonly requireLowercase?: boolean;
readonly requireUppercase?: boolean;
readonly requireDigits?: boolean;
readonly requireSymbols?: boolean;
}
/**
* Options accepted by `generatePassword` and `generatePronounceablePassword`.
* The character-class flags (`lowercase`/`uppercase`/`digits`/`symbols`) all
* default to `true`; pass `false` to omit a class from the effective union.
* `exclude` subtracts characters from the union after classes are merged.
*/
export interface GeneratePasswordOptions
extends CharsetOptions,
RequiredClasses {
readonly length: number;
readonly mode?: PasswordMode;
}

View File

@@ -0,0 +1,72 @@
# `pronounceable`
## Purpose
`generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for shoulder-surf-friendly UX contexts where the user must read the password aloud or type it from memory in a short window. The output alternates consonant-frequency-weighted picks from a 21-character consonant set and 5-character vowel set, drawn via the bias-free `randomInt` primitive. Pronounceable passwords trade entropy for memorability: per-character entropy is ≈ log2(21·5)/2 ≈ 4.7 bits versus ≈ 6.55 bits for a full 94-character symbol set, so callers SHOULD pair this mode with a longer `length` or reserve it strictly for UX contexts where the user re-types the password by hand.
The consonant ordering is derived from Norvig's 2013 English-corpus letter frequencies. Picks are uniform over the 21-character consonant set (not frequency-biased), but the choice of which consonants appear at all reflects common English digrams — `q` and `x` are present but produce visibly less "English-shaped" syllables than `t` and `n`.
## Features
- 3-character CVC syllables (`[bcdfghjklmnpqrstvwxyz][aeiou][bcdfghjklmnpqrstvwxyz]`).
- Truncates the final syllable when `length % 3 !== 0`.
- Deterministic under a seeded `Rng`.
- Throws `PWGEN.INVALID_OPTIONS` for `length < 1` or non-integer `length`.
- Emits `pwgen.generate_duration_ms{mode=pronounceable}` and `pwgen.entropy_bits{mode=pronounceable}` on success.
## Usage: Basic Example
```ts
import { generatePronounceablePassword } from "@suaveplan/password-generator";
const pw = generatePronounceablePassword({ length: 12, mode: "pronounceable" });
```
## Usage: Advanced Example
```ts
import type { Rng } from "@suaveplan/types";
import { generatePronounceablePassword } from "@suaveplan/password-generator";
function seeded(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;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
// Pair with longer length to recover entropy lost to the CVC constraint.
// 18 chars ≈ 18 * 4.7 ≈ 85 bits.
const pw = generatePronounceablePassword(
{ length: 18, mode: "pronounceable" },
seeded(1234),
);
```
## API Reference
### `generatePronounceablePassword(opts: GeneratePasswordOptions & { mode: "pronounceable" }, rng?: Rng): string`
- `opts.length`: positive integer total length. The last syllable is truncated to fit.
- `opts.mode`: must be `"pronounceable"`; type discriminator only.
- `rng`: defaults to `defaultRng()`.
### Constants
- `PRONOUNCEABLE_CONSONANTS: string` — the 21-character consonant set ordered by English frequency.
- `PRONOUNCEABLE_VOWELS: string` — `aeiou`.
## Implementation Notes
The generator never reaches Markov-state complexity — instead the CVC pattern is encoded directly in the position index. At output index `i`, `idx = i % 3` selects between the consonant set (`idx === 0` or `idx === 2`) and the vowel set (`idx === 1`). This sidesteps the digram-table lookups Markov-style generators perform and produces strictly uniform draws within each class.
Frequency-weighted picks (where common consonants like `t` and `n` would appear more often than rare ones like `q` and `x`) would make output more English-shaped at the cost of further entropy reduction. The trade-off was decided in favour of uniform per-class picks because the package's `assertStrength` measurement assumes the entropy formula `log2(charset) * length`, which only holds under uniform sampling. Switching to frequency-weighted picks would require carrying a separate empirical entropy estimate per locale.

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import {
generatePronounceablePassword,
PRONOUNCEABLE_CONSONANTS,
PRONOUNCEABLE_VOWELS,
} from "./pronounceable.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("generatePronounceablePassword", () => {
it("emits CVC syllables of total length N", { timeout: 5000 }, () => {
const pw = generatePronounceablePassword({
length: 12,
mode: "pronounceable",
});
expect(pw.length).toBe(12);
for (let i = 0; i < pw.length; i++) {
const ch = pw[i] as string;
const idx = i % 3;
if (idx === 1) {
expect(PRONOUNCEABLE_VOWELS.includes(ch)).toBe(true);
} else {
expect(PRONOUNCEABLE_CONSONANTS.includes(ch)).toBe(true);
}
}
});
it("deterministic under seeded Rng", { timeout: 5000 }, () => {
const a = generatePronounceablePassword(
{ length: 12, mode: "pronounceable" },
seededRng(5)
);
const b = generatePronounceablePassword(
{ length: 12, mode: "pronounceable" },
seededRng(5)
);
expect(a).toBe(b);
});
it("truncates last syllable for non-multiple-of-3 length", {
timeout: 5000,
}, () => {
const pw = generatePronounceablePassword({
length: 7,
mode: "pronounceable",
});
expect(pw.length).toBe(7);
});
it("rejects invalid length with PWGEN.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(() =>
generatePronounceablePassword({ length: 0, mode: "pronounceable" })
).toThrow(/PWGEN\.INVALID_OPTIONS/);
expect(() =>
generatePronounceablePassword({
length: 1.5,
mode: "pronounceable",
})
).toThrow(/PWGEN\.INVALID_OPTIONS/);
});
});

View File

@@ -0,0 +1,57 @@
import type { Rng } from "@suaveplan/types";
import { estimateEntropy } 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 type { GeneratePasswordOptions } from "./password.types.js";
/**
* Consonants ordered by English letter frequency (Norvig 2013 corpus
* analysis). Bias toward common consonants improves pronounceability
* without harming uniform-letter entropy by more than ≈ 0.5 bits/char.
*/
const CONSONANTS = "tnsrhdlcmpfgwybvkxjqz";
const VOWELS = "aeiou";
/**
* Generate a CVC-pattern pronounceable password of `length` characters by
* concatenating consonant–vowel–consonant syllables. The last syllable is
* truncated to fit `length` exactly when `length % 3 !== 0`.
*
* Pronounceable output has materially lower entropy than the char-class
* generator (per-char ≈ log2(21·5) ≈ 6.7 bits vs. log2(94) ≈ 6.55 for full
* symbols, but the constraint on alternating C-V-C reduces the effective
* search space). Callers SHOULD pair this mode with a longer `length` or
* reserve it for shoulder-surf-friendly UX contexts.
*
* @throws PwgenError(PWGEN.INVALID_OPTIONS) when `length` is not a positive integer.
*/
export function generatePronounceablePassword(
opts: GeneratePasswordOptions & { mode: "pronounceable" },
rng?: Rng
): string {
if (!Number.isInteger(opts.length) || opts.length < 1) {
recordError("PWGEN.INVALID_OPTIONS");
throw pwgenError(
"PWGEN.INVALID_OPTIONS",
`length must be a positive integer; received ${opts.length}`
);
}
const effectiveRng = rng ?? defaultRng();
const out: string[] = [];
while (out.length < opts.length) {
const idx = out.length % 3;
const source = idx === 1 ? VOWELS : CONSONANTS;
out.push(source[randomInt(effectiveRng, source.length)] as string);
}
const start = performance.now();
const joined = out.join("");
const { bits } = estimateEntropy(joined);
recordGeneration("pronounceable", performance.now() - start, bits);
return joined;
}
/** Exposed for tests so the CVC pattern can be asserted deterministically. */
export const PRONOUNCEABLE_CONSONANTS = CONSONANTS;
export const PRONOUNCEABLE_VOWELS = VOWELS;

View File

@@ -0,0 +1 @@
export { generatePin } from "./pin.js";

View File

@@ -0,0 +1,66 @@
# `pin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`generatePin(length, rng?)` returns a `length`-character PIN drawn uniformly from `[0-9]` via the bias-free `randomInt(rng, 10)` primitive. PINs are intentionally a separate surface from `generatePassword` because the constraint set is narrower (only digits, no class quotas, no exclusion arithmetic) and call-site UX is different — banking apps, SIM lock screens, and confirmation flows want a tight `generatePin(6)` rather than a `generatePassword({ length: 6, lowercase: false, uppercase: false, symbols: false })` configuration cliff.
The per-digit entropy is `log2(10) ≈ 3.32` bits, so a 6-digit PIN carries ≈ 19.9 bits and a 12-digit PIN ≈ 39.9 bits. PINs SHOULD be paired with rate-limiting or lockout policies at the call site; this generator is responsible only for producing the digits, not for protecting the channel that submits them.
## Features
- Bias-free uniform draws over `[0, 10)`.
- Throws `PWGEN.OPTIONS_INFEASIBLE` for `length < 1` or non-integer `length`.
- Deterministic under a seeded `Rng`.
- Emits `pwgen.generate_duration_ms{mode=pin}` and `pwgen.entropy_bits{mode=pin}` on success.
## Usage: Basic Example
```ts
import { generatePin } from "@suaveplan/password-generator";
const pin = generatePin(6); // "049213"
```
## Usage: Advanced Example
```ts
import type { Rng } from "@suaveplan/types";
import { generatePin } from "@suaveplan/password-generator";
function seeded(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;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
// Replay a known PIN for tests
const replayed = generatePin(12, seeded(42));
```
## API Reference
### `generatePin(length: number, rng?: Rng): string`
- `length`: positive integer total digit count.
- `rng`: defaults to `defaultRng()`.
Throws `PwgenError(PWGEN.OPTIONS_INFEASIBLE)` when `length < 1` or `length` is non-integer.
## Implementation Notes
The function loops `length` times calling `randomInt(rng, 10).toString()` and concatenates the result. Building the output via `String#concat` rather than `Array.join` keeps the hot path allocation-light — for the typical 4-to-12-digit range the JIT keeps the buffer in registers without intermediate array creation.
Distribution conformance is tested by drawing 100 000 digits and asserting each of the 10 buckets falls within ±10% of the expected `total / 10`. The χ² statistical test owned by `randomInt` is what guarantees uniform per-bucket distribution; the PIN-specific test exists only to catch a regression where `generatePin` introduces its own bias on top of `randomInt`.

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { generatePin } from "./pin.js";
describe("generatePin — distribution conformance", () => {
it("distributes evenly across [0,9] over 100k digits", {
timeout: 30000,
}, () => {
const total = 100_000;
const pin = generatePin(total);
const counts = new Int32Array(10);
for (const ch of pin) {
counts[ch.charCodeAt(0) - 48] =
(counts[ch.charCodeAt(0) - 48] as number) + 1;
}
const expected = total / 10;
for (const c of counts) {
expect(c).toBeGreaterThan(expected * 0.9);
expect(c).toBeLessThan(expected * 1.1);
}
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { generatePin } from "./pin.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("generatePin", () => {
it("generates a length-6 PIN", { timeout: 5000 }, () => {
const pin = generatePin(6);
expect(pin.length).toBe(6);
expect(/^[0-9]{6}$/.test(pin)).toBe(true);
});
it("rejects zero length", { timeout: 5000 }, () => {
expect(() => generatePin(0)).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
});
it("rejects negative length", { timeout: 5000 }, () => {
expect(() => generatePin(-1)).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
});
it("rejects non-integer length", { timeout: 5000 }, () => {
expect(() => generatePin(2.5)).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
});
it("is deterministic under seeded Rng", { timeout: 5000 }, () => {
const a = generatePin(8, seededRng(123));
const b = generatePin(8, seededRng(123));
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,35 @@
import type { Rng } from "@suaveplan/types";
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";
const ENTROPY_BITS_PER_DIGIT = Math.log2(10);
/**
* Generate a `length`-character PIN drawn uniformly from `[0-9]` via the
* bias-free `randomInt(rng, 10)`.
*
* @throws PwgenError(PWGEN.OPTIONS_INFEASIBLE) when `length < 1`.
*/
export function generatePin(length: number, rng?: Rng): string {
if (!Number.isInteger(length) || length < 1) {
recordError("PWGEN.OPTIONS_INFEASIBLE");
throw pwgenError(
"PWGEN.OPTIONS_INFEASIBLE",
`PIN length must be a positive integer; received ${length}`
);
}
const effectiveRng = rng ?? defaultRng();
const start = performance.now();
let out = "";
for (let i = 0; i < length; i++) {
out += randomInt(effectiveRng, 10).toString();
}
recordGeneration(
"pin",
performance.now() - start,
ENTROPY_BITS_PER_DIGIT * length
);
return out;
}

View File

@@ -0,0 +1,54 @@
# `default-rng`
## Purpose
`defaultRng()` builds the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`, which delegates to `crypto.getRandomValues` and is therefore cryptographically strong on every supported runtime (Bun, Node ≥ 20, Deno, the browser). The function is invoked lazily by each generator when the caller does not supply their own `Rng`, which means consumers that always pass a deterministic seeded `Rng` (tests, replay logs) never reach `crypto.getRandomValues` at all and tree-shakers can drop this module.
Keeping the default in a separate file (rather than inline at every generator's call site) gives a single canonical place to swap entropy sources — for example to wire a hardware RNG adapter — without touching the generators themselves.
## Features
- Wraps `randomBytes(4)` to produce a uint32 per `nextUint32` call.
- Optional `nextBytes(n)` exposed for the rare consumer that wants raw entropy.
- Optional `nextFloat()` computed from `nextUint32() / 2^32`.
- Zero module-scope state — every call to `defaultRng()` returns a fresh `Rng`.
## Usage: Basic Example
```ts
import { defaultRng } from "@suaveplan/password-generator/random";
const rng = defaultRng();
const u = rng.nextUint32(); // ∈ [0, 2^32)
```
## Usage: Advanced Example
```ts
import { randomInt, defaultRng } from "@suaveplan/password-generator/random";
import { generatePassword } from "@suaveplan/password-generator/password";
// The two snippets below are equivalent — generatePassword falls back to
// defaultRng() when no rng is supplied.
generatePassword({ length: 16 });
generatePassword({ length: 16 }, defaultRng());
// Explicit rng is preferable inside loops so the same instance is reused
// across many calls without re-importing crypto each time.
const rng = defaultRng();
for (let i = 0; i < 100; i++) {
randomInt(rng, 7776);
}
```
## API Reference
### `defaultRng(): Rng`
Builds a fresh `Rng` whose `nextUint32` reads four bytes from `@suaveplan/crypto-utils.randomBytes` and assembles them into a big-endian uint32. The returned object also implements the optional `nextFloat` (uint32 normalised into `[0, 1)`) and `nextBytes` (direct passthrough to `randomBytes(n)`) members of the `Rng` contract.
## Implementation Notes
The bit layout for `nextUint32` is `(b0 << 24) | (b1 << 16) | (b2 << 8) | b3`, with the final `>>> 0` to coerce JavaScript's 32-bit signed arithmetic back into an unsigned integer. The same big-endian ordering is consistent across runtimes — `crypto.getRandomValues` makes no endianness promises, but since every byte is independent and uniform the assembly order does not affect the distribution.
`nextFloat` is intentionally implemented as `nextUint32() / 2^32` rather than `Math.random()` so the package contains _zero_ references to `Math.random` (rule 8 of the source-tree hygiene gate). The float resolution is 32-bit; consumers that need 53-bit precision should derive it from two `nextUint32` draws themselves.

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defaultRng } from "./default-rng.js";
describe("defaultRng", () => {
it("nextUint32 returns an integer in [0, 2^32)", { timeout: 5000 }, () => {
const rng = defaultRng();
for (let i = 0; i < 100; i++) {
const v = rng.nextUint32();
expect(Number.isInteger(v)).toBe(true);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(0x1_0000_0000);
}
});
it("nextFloat returns values in [0, 1)", { timeout: 5000 }, () => {
const rng = defaultRng();
for (let i = 0; i < 100; i++) {
const v = rng.nextFloat();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it("nextBytes returns an n-byte buffer", { timeout: 5000 }, () => {
const rng = defaultRng();
const bytes = rng.nextBytes?.(16);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes?.length).toBe(16);
});
});

View File

@@ -0,0 +1,29 @@
import { randomBytes } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
/**
* Build the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`
* (which delegates to `crypto.getRandomValues`). Constructed lazily so
* importers that pass their own `Rng` never reach `crypto` at all and
* tree-shakers can drop this module.
*/
export function defaultRng(): Rng {
return {
nextUint32(): number {
const bytes = randomBytes(4);
return (
(((bytes[0] as number) << 24) |
((bytes[1] as number) << 16) |
((bytes[2] as number) << 8) |
(bytes[3] as number)) >>>
0
);
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
nextBytes(n: number): Uint8Array {
return randomBytes(n);
},
};
}

View File

@@ -0,0 +1,64 @@
# `fisher-yates`
## Purpose
`shuffleInPlace(array, rng)` performs an in-place Fisher–Yates / Knuth shuffle parameterised over the bias-free `randomInt` primitive. The shuffle is the load-bearing step that prevents required-class characters from being biased to the head of `generatePassword`'s output buffer — without it, the first N characters of every generated password would always be the required-class quotas in declaration order, leaking information about the generator's option set to anyone who inspects the produced string.
The standard Knuth analysis guarantees that every permutation of `array` appears with equal probability, _provided_ the underlying integer draws are uniform. Pairing the algorithm with the bias-free `randomInt` rather than `Math.floor(Math.random() * n)` is what makes that guarantee hold for arbitrary `array.length`.
## Features
- Cryptographically-unbiased permutations via Lemire-style rejection sampling.
- In-place mutation; returns the same array reference for fluent chaining.
- No-op for arrays of length 0 or 1.
- Pure given `rng`; deterministic under a seeded `Rng`.
## Usage: Basic Example
```ts
import { defaultRng, shuffleInPlace } from "@suaveplan/password-generator/random";
const arr = [1, 2, 3, 4, 5];
shuffleInPlace(arr, defaultRng());
```
## Usage: Advanced Example
```ts
import type { Rng } from "@suaveplan/types";
import { shuffleInPlace } from "@suaveplan/password-generator/random";
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;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
// Reproducible card-deck shuffle for replay logs
const deck = [...Array(52).keys()];
shuffleInPlace(deck, seededRng(123));
```
## API Reference
### `shuffleInPlace<T>(array: T[], rng: Rng): T[]`
- `array`: mutated in-place. The reference is returned so consumers can chain.
- `rng`: any `Rng`; only `nextUint32` is used (via `randomInt`).
- Returns: the same `array` reference.
## Implementation Notes
The implementation walks `i` from `array.length - 1` down to `1`. At each step it draws `j = randomInt(rng, i + 1)` and swaps positions `i` and `j`. Bias-free uniform draws are critical: a biased `randomInt` would skew certain permutations and the standard distributional argument would fail.
The shuffle complexity is `O(array.length)` with one `randomInt` call per element except the last. Memory complexity is `O(1)` beyond the input array. Returning the same array reference lets callers compose `shuffleInPlace(...).map(...)` without an explicit local variable; consumers who need an immutable shuffle should clone the input first.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { defaultRng } from "./default-rng.js";
import { shuffleInPlace } from "./fisher-yates.js";
function seededRng(seed: number): Rng {
// Mulberry32 — well-distributed for 32-bit seeds.
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("shuffleInPlace", () => {
it("preserves length", { timeout: 5000 }, () => {
const arr = [1, 2, 3, 4, 5];
shuffleInPlace(arr, defaultRng());
expect(arr.length).toBe(5);
});
it("preserves multiset of elements", { timeout: 5000 }, () => {
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const original = [...arr];
shuffleInPlace(arr, defaultRng());
expect([...arr].sort((a, b) => a - b)).toEqual(original);
});
it("is deterministic under seeded Rng", { timeout: 5000 }, () => {
const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const b = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
shuffleInPlace(a, seededRng(42));
shuffleInPlace(b, seededRng(42));
expect(a).toEqual(b);
});
it("is no-op for arrays of length 0 or 1", { timeout: 5000 }, () => {
expect(shuffleInPlace([], defaultRng())).toEqual([]);
expect(shuffleInPlace(["x"], defaultRng())).toEqual(["x"]);
});
it("returns the same array reference", { timeout: 5000 }, () => {
const arr = [1, 2, 3];
const result = shuffleInPlace(arr, defaultRng());
expect(result).toBe(arr);
});
});

View File

@@ -0,0 +1,24 @@
import type { Rng } from "@suaveplan/types";
import { randomInt } from "./random-int.js";
/**
* Fisher–Yates in-place shuffle. Uses the bias-free
* {@link randomInt} so the resulting permutation distribution is
* uniform regardless of `array.length`.
*
* The shuffle iterates from the end of the array down to index 1; at each
* step it swaps the current element with a uniformly-chosen element from
* the prefix `[0, i]`. This guarantees every permutation appears with
* equal probability (the standard Knuth analysis).
*
* Returns the same array reference for fluent chaining.
*/
export function shuffleInPlace<T>(array: T[], rng: Rng): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = randomInt(rng, i + 1);
const tmp = array[i] as T;
array[i] = array[j] as T;
array[j] = tmp;
}
return array;
}

View File

@@ -0,0 +1,3 @@
export { defaultRng } from "./default-rng.js";
export { shuffleInPlace } from "./fisher-yates.js";
export { randomInt } from "./random-int.js";

View File

@@ -0,0 +1,68 @@
# `random-int`
## Purpose
`randomInt(rng, max)` returns a uniformly-distributed integer in the half-open interval `[0, max)` for any `max ∈ [1, 2^32)` by drawing uint32 values from the supplied `Rng` and rejecting draws above the threshold `2^32 - (2^32 mod max)`. This is the load-bearing primitive every generator in the package relies on for unbiased sampling — replacing it with the naïve `Math.floor(rng() * max)` shortcut would silently bias output for non-power-of-two `max`, the very common case for 7776-entry word lists and 10-entry digit picks.
The implementation follows Daniel Lemire's 2018 _Fast Random Integer Generation in an Interval_ (https://arxiv.org/abs/1805.10941) §3 ("Nearly Divisionless"). Expected per-call draws are below 2 in the worst case; for the EFF Long list (`max = 7776`) the rejection probability is < 0.0000019 per draw, so the bounded retry budget (1024 attempts) acts purely as a guard against a pathologically broken `Rng`.
## Features
- Strictly uniform output across `[0, max)`.
- Rejection-sampling loop with a 1024-attempt safety bound.
- Throws `PWGEN.RNG_FAILED` for invalid `max` or pathological RNGs.
- Pure function; no I/O, no module-scope state.
## Usage: Basic Example
```ts
import { defaultRng } from "@suaveplan/password-generator/random";
import { randomInt } from "@suaveplan/password-generator/random";
const rng = defaultRng();
const v = randomInt(rng, 7776); // ∈ [0, 7776)
```
## Usage: Advanced Example
```ts
import type { Rng } from "@suaveplan/types";
import { randomInt } from "@suaveplan/password-generator/random";
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;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
// Deterministic replay for tests
const rng = seededRng(42);
const a = randomInt(rng, 256);
const b = randomInt(rng, 256);
```
## API Reference
### `randomInt(rng: Rng, max: number): number`
Returns a uniform integer in `[0, max)`.
- `rng`: any `Rng` from `@suaveplan/types`. Only `nextUint32` is used.
- `max`: must satisfy `Number.isInteger(max) && 1 <= max < 2^32`.
- Throws `PwgenError(PWGEN.RNG_FAILED)` when `max` is out of range or when the rejection loop exhausts its 1024-attempt budget.
## Implementation Notes
The threshold `T = 2^32 - (2^32 mod max)` is the largest multiple of `max` below `2^32`. Any uint32 in `[0, T)` is uniformly distributed across the `T / max` complete copies of `[0, max)` it covers; values in `[T, 2^32)` are rejected and the next draw is taken. This makes the output strictly uniform at the cost of an expected `< 2` calls to `rng.nextUint32` for typical `max`.
The retry budget guards against an `Rng` that always returns values above the threshold — without the bound, a broken RNG would loop forever. 1024 attempts is far beyond any non-pathological RNG: even with `max = 7776` the probability of 1024 consecutive rejections is below `(7776 / 2^32)^1024`, indistinguishable from zero. Reaching the bound therefore proves the supplied `Rng` is broken and the function throws `PWGEN.RNG_FAILED` instead of hanging.

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defaultRng } from "./default-rng.js";
import { randomInt } from "./random-int.js";
/**
* Wilson-Hilferty transform: a chi-squared distribution with `df` degrees
* of freedom can be approximated as a Normal((1 - 2/(9 df)), √(2/(9 df))³)
* after the cube-root transform `(X/df)^(1/3)`. We then convert to
* a standard normal Z-score and return the two-sided p-value from a
* Hastings (1955) rational approximation. Avoids pulling a stats lib.
*/
function chiSquaredPValue(chi2: number, df: number): number {
const cubeRoot = Math.cbrt(chi2 / df);
const mean = 1 - 2 / (9 * df);
const sd = Math.sqrt(2 / (9 * df));
const z = (cubeRoot - mean) / sd;
// One-sided survival of standard normal — chi-squared p-value is the
// upper-tail probability, so a small `chi2` gives a large p.
return survivalNormal(z);
}
function survivalNormal(z: number): number {
// Abramowitz & Stegun 26.2.17 — accurate to ~7.5e-8.
const cdf = (x: number): number => {
const sign = x < 0 ? -1 : 1;
const ax = Math.abs(x) / Math.SQRT2;
const t = 1 / (1 + 0.3275911 * ax);
const a1 = 0.254829592;
const a2 = -0.284496736;
const a3 = 1.421413741;
const a4 = -1.453152027;
const a5 = 1.061405429;
const erf =
1 -
((((a5 * t + a4) * t + a3) * t + a2) * t + a1) *
t *
Math.exp(-ax * ax);
return 0.5 * (1 + sign * erf);
};
return 1 - cdf(z);
}
describe("randomInt — statistical conformance", () => {
it("1M-sample chi-squared p > 0.01 over n=7776 buckets", {
timeout: 30000,
}, () => {
const n = 7776;
const samples = 1_000_000;
const counts = new Int32Array(n);
const rng = defaultRng();
for (let i = 0; i < samples; i++) {
const v = randomInt(rng, n);
counts[v] = (counts[v] as number) + 1;
}
const expected = samples / n;
let chi2 = 0;
for (let i = 0; i < n; i++) {
const diff = (counts[i] as number) - expected;
chi2 += (diff * diff) / expected;
}
const p = chiSquaredPValue(chi2, n - 1);
expect(p).toBeGreaterThan(0.01);
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { defaultRng } from "./default-rng.js";
import { randomInt } from "./random-int.js";
function fixedRng(values: number[]): Rng {
let i = 0;
return {
nextUint32(): number {
const v = values[i++] ?? values[values.length - 1] ?? 0;
return v >>> 0;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
describe("randomInt", () => {
it("returns value in [0, max)", { timeout: 5000 }, () => {
const rng = defaultRng();
for (let i = 0; i < 10_000; i++) {
const v = randomInt(rng, 7776);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(7776);
expect(Number.isInteger(v)).toBe(true);
}
});
it("triggers rejection branch above threshold", { timeout: 5000 }, () => {
const max = 7776;
const threshold = 0x1_0000_0000 - (0x1_0000_0000 % max);
let calls = 0;
const rng: Rng = {
nextUint32(): number {
calls++;
return calls === 1 ? threshold + 5 : 42;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
};
const v = randomInt(rng, max);
expect(calls).toBe(2);
expect(v).toBe(42 % max);
});
it("throws PWGEN.RNG_FAILED on pathological RNG", { timeout: 5000 }, () => {
const max = 7776;
const threshold = 0x1_0000_0000 - (0x1_0000_0000 % max);
const rng = fixedRng([threshold + 1]);
expect(() => randomInt(rng, max)).toThrow(/PWGEN\.RNG_FAILED/);
});
it("throws PWGEN.RNG_FAILED for invalid max", { timeout: 5000 }, () => {
const rng = defaultRng();
expect(() => randomInt(rng, 0)).toThrow(/PWGEN\.RNG_FAILED/);
expect(() => randomInt(rng, -1)).toThrow(/PWGEN\.RNG_FAILED/);
expect(() => randomInt(rng, 1.5)).toThrow(/PWGEN\.RNG_FAILED/);
expect(() => randomInt(rng, 0x1_0000_0000)).toThrow(
/PWGEN\.RNG_FAILED/
);
});
it("reduces uint32 modulo max for in-range draw", { timeout: 5000 }, () => {
const rng = fixedRng([100]);
expect(randomInt(rng, 50)).toBe(0);
});
});

View File

@@ -0,0 +1,50 @@
import type { Rng } from "@suaveplan/types";
import { pwgenError } from "../errors/codes.js";
/**
* Bounded retry budget for the rejection loop. A correctly-implemented
* `Rng.nextUint32` produces a uniformly-distributed uint32 every call; the
* worst-case rejection probability is `(2^32 mod max) / 2^32 < max/2^32`,
* which is < 0.0000019 for `max = 7776`. 1024 attempts give a worst-case
* success probability of `1 - (max/2^32)^1024`, indistinguishable from 1.
* Hitting this cap therefore proves the supplied `Rng` is broken.
*/
const REJECTION_RETRY_BUDGET = 1024;
/** Exclusive upper bound of the uint32 space. */
const TWO_POW_32 = 0x1_0000_0000;
/**
* Return a uniformly-distributed integer in the half-open interval
* `[0, max)` using Lemire-style rejection sampling.
*
* Reference: Daniel Lemire, _Fast Random Integer Generation in an
* Interval_, ACM Trans. Model. Comput. Simul. 2019,
* https://arxiv.org/abs/1805.10941 §3 ("Nearly Divisionless").
*
* The naïve `Math.floor(rng() * max)` is biased for non-power-of-two
* `max`. This implementation draws a uint32 and rejects values above the
* threshold `2^32 - (2^32 mod max)`, re-drawing on rejection. The bias
* disappears at the cost of an expected `< 2` draws per call.
*
* @throws PwgenError(PWGEN.RNG_FAILED) if `rng.nextUint32` returns
* non-uint32 values for {@link REJECTION_RETRY_BUDGET} consecutive
* attempts.
*/
export function randomInt(rng: Rng, max: number): number {
if (!Number.isInteger(max) || max < 1 || max >= TWO_POW_32) {
throw pwgenError(
"PWGEN.RNG_FAILED",
`randomInt requires max in [1, 2^32); received ${max}`
);
}
const threshold = TWO_POW_32 - (TWO_POW_32 % max);
for (let attempt = 0; attempt < REJECTION_RETRY_BUDGET; attempt++) {
const draw = rng.nextUint32() >>> 0;
if (draw < threshold) return draw % max;
}
throw pwgenError(
"PWGEN.RNG_FAILED",
`Rng failed to produce an in-range uint32 after ${REJECTION_RETRY_BUDGET} attempts`
);
}

View File

@@ -0,0 +1,5 @@
export {
EntropyOptionsSchema,
GeneratePassphraseOptionsSchema,
GeneratePasswordOptionsSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,60 @@
# `schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the Zod boundary schemas that every public generator function uses to validate its `opts` argument. Schema mismatch throws `PwgenError(PWGEN.INVALID_OPTIONS)` with the original `ZodError` attached as `cause`, so callers can introspect the failing field path while keeping the typed error code stable for logging.
Strict-mode schemas reject unknown fields rather than silently dropping them — typos like `requrieDigits` (with a transposed `i`) surface immediately instead of being ignored at runtime. Three schemas are exported: `GeneratePasswordOptionsSchema`, `GeneratePassphraseOptionsSchema`, and `EntropyOptionsSchema`.
## Features
- Strict-object schemas reject unknown fields.
- Positive-integer guards on `length`-shaped fields.
- Discriminated `wordlist` accepts `"long"`, `"short"`, an inline `string[]`, or any registry name.
- All three schemas are invoked from at least one production call site (the conformance script verifies this).
## Usage: Basic Example
```ts
import { GeneratePasswordOptionsSchema } from "@suaveplan/password-generator/schemas";
const result = GeneratePasswordOptionsSchema.safeParse({ length: 16 });
if (!result.success) throw result.error;
```
## Usage: Advanced Example
```ts
import {
EntropyOptionsSchema,
GeneratePassphraseOptionsSchema,
} from "@suaveplan/password-generator/schemas";
// Pre-validate user-supplied JSON before forwarding to the generator
function fromJson(raw: unknown) {
const parsed = GeneratePassphraseOptionsSchema.parse(raw);
return parsed;
}
// Compose with downstream schemas
import z from "zod";
const apiBody = z.object({
mode: z.literal("passphrase"),
opts: GeneratePassphraseOptionsSchema,
});
```
## API Reference
- `GeneratePasswordOptionsSchema` — strict object mirroring `GeneratePasswordOptions`.
- `GeneratePassphraseOptionsSchema` — strict object mirroring `GeneratePassphraseOptions`. `length` is optional in the schema (the generator applies the default `6`).
- `EntropyOptionsSchema` — strict object mirroring `EntropyOptions`.
## Implementation Notes
The schemas live in a single feature module to keep the conformance script's `src/schemas/` discovery simple — the script walks that directory and verifies every exported `*Schema` is invoked via `.parse(` or `.safeParse(` somewhere outside the directory. This pattern also matches the layout the `cbor`, `validation`, and `vault` packages already use, so reviewers don't need to context-switch.
Strict-object semantics are non-negotiable for boundary surfaces — Zod's default `passthrough` would let typos silently pass through to the generator and corrupt observability (a `requireDigit` typo with a missing `s` would not enable required-digit quotas but `recordError` would never fire). The trade-off — slightly more verbose error messages when adding new options forces a schema update in the same commit — is the entire point of the gate.

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { estimateEntropy } from "../entropy/entropy.js";
import { generatePassphrase } from "../passphrase/passphrase.js";
import { generatePassword } from "../password/password.js";
import {
EntropyOptionsSchema,
GeneratePassphraseOptionsSchema,
GeneratePasswordOptionsSchema,
} from "./schemas.js";
describe("Zod boundary schemas", () => {
it("GeneratePasswordOptionsSchema rejects negative length", {
timeout: 5000,
}, () => {
expect(() => generatePassword({ length: -1 })).toThrow(
/PWGEN\.INVALID_OPTIONS/
);
});
it("GeneratePasswordOptionsSchema accepts the documented option shape", {
timeout: 5000,
}, () => {
const r = GeneratePasswordOptionsSchema.safeParse({
length: 16,
lowercase: true,
uppercase: false,
digits: true,
symbols: false,
requireDigits: true,
exclude: "abc",
});
expect(r.success).toBe(true);
});
it("GeneratePassphraseOptionsSchema rejects zero length", {
timeout: 5000,
}, () => {
expect(() => generatePassphrase({ length: 0 })).toThrow(
/PWGEN\.INVALID_OPTIONS/
);
});
it("GeneratePassphraseOptionsSchema rejects unknown fields", {
timeout: 5000,
}, () => {
const r = GeneratePassphraseOptionsSchema.safeParse({
length: 4,
bogus: true,
});
expect(r.success).toBe(false);
});
it("EntropyOptionsSchema rejects malformed extraClasses", {
timeout: 5000,
}, () => {
expect(() =>
estimateEntropy("abc", { extraClasses: 42 as never })
).toThrow(/PWGEN\.INVALID_OPTIONS/);
});
it("EntropyOptionsSchema accepts a string[] extraClasses", {
timeout: 5000,
}, () => {
const r = EntropyOptionsSchema.safeParse({ extraClasses: ["xyz"] });
expect(r.success).toBe(true);
});
});

View File

@@ -0,0 +1,62 @@
import z from "zod";
/** Reusable count schema — `length` arguments must be positive integers. */
const PositiveLength = z.number().int().positive();
/**
* Zod schema mirroring `GeneratePasswordOptions`. The shape stays strict so
* unknown fields propagate as `PWGEN.INVALID_OPTIONS` and callers see typos
* at the boundary instead of silently-ignored options.
*/
export const GeneratePasswordOptionsSchema = z
.object({
length: PositiveLength,
lowercase: z.boolean().optional(),
uppercase: z.boolean().optional(),
digits: z.boolean().optional(),
symbols: z.boolean().optional(),
requireLowercase: z.boolean().optional(),
requireUppercase: z.boolean().optional(),
requireDigits: z.boolean().optional(),
requireSymbols: z.boolean().optional(),
exclude: z
.union([
z.string(),
z.array(z.string()),
z.instanceof(Set as unknown as { new (): Set<string> }),
])
.optional(),
mode: z.enum(["random", "pronounceable"]).optional(),
})
.strict();
const CapitalizationMode = z.enum(["none", "firstChar", "randomWord"]);
const WordlistRef = z.union([
z.literal("long"),
z.literal("short"),
z.string().min(1),
z.array(z.string()),
]);
/**
* Zod schema mirroring `GeneratePassphraseOptions`. `length` defaults to 6
* in the call site, not in the schema, so the schema rejects `0` rather
* than silently coercing it into the default.
*/
export const GeneratePassphraseOptionsSchema = z
.object({
length: PositiveLength.optional(),
wordlist: WordlistRef.optional(),
separator: z.string().optional(),
capitalize: CapitalizationMode.optional(),
addDigits: z.number().int().nonnegative().optional(),
addSymbols: z.number().int().nonnegative().optional(),
})
.strict();
/** Zod schema for `EntropyOptions`. */
export const EntropyOptionsSchema = z
.object({
extraClasses: z.array(z.string().min(1)).optional(),
})
.strict();

View File

@@ -0,0 +1,48 @@
# `eff-long` — EFF Long Wordlist
## Purpose
Bundles the EFF Long Wordlist as a frozen `readonly string[]` so callers who only want passphrase generation pull this 85 KB asset and nothing else. The list contains exactly 7776 entries (5-dice / 6⁵), giving ≈ 12.92 bits of entropy per word and 77.55 bits for a 6-word passphrase. Source: https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt (Electronic Frontier Foundation, released under CC BY 3.0 US).
The wordlist is exposed via two surfaces: the registry name `"eff-long"` (via `getWordlist`) and the dedicated subpath import `@suaveplan/password-generator/wordlists/eff-long`. The subpath import lets a tree-shaker drop the array entirely from a bundle that only generates char-class passwords or PINs.
## Features
- Exactly 7776 ASCII entries.
- Deduplicated and prefix-disambiguated within the first three characters (per EFF's selection criteria).
- Each word is 3–9 characters and contains only `[a-z-]`.
- Frozen array — safe to share across modules.
## Usage: Basic Example
```ts
import { EFF_LONG_WORDLIST } from "@suaveplan/password-generator/wordlists/eff-long";
console.log(EFF_LONG_WORDLIST[0]); // "abacus"
```
## Usage: Advanced Example
```ts
import { generatePassphrase } from "@suaveplan/password-generator";
import { EFF_LONG_WORDLIST } from "@suaveplan/password-generator/wordlists/eff-long";
// Verify a passphrase came from the EFF Long list
const pp = generatePassphrase({});
const longSet = new Set(EFF_LONG_WORDLIST);
for (const w of pp.split("-")) {
if (!longSet.has(w) && !w.includes("-")) throw new Error("not from EFF Long");
}
```
## API Reference
### `EFF_LONG_WORDLIST: readonly string[]`
Frozen array of 7776 lowercase ASCII strings. Mutation attempts throw at runtime in strict mode.
## Implementation Notes
The wordlist is embedded verbatim from the EFF source file. Re-generating it (for example to localise to a non-English language) requires verifying the prefix-disambiguation property: every pair of entries differs within the first three characters, which makes typed passphrases auto-completable without ambiguity in EFF's documented dice-rolling protocol. The package does not re-verify this property at runtime because the array is constant — it's the responsibility of any caller who re-bundles a translated list to maintain the property.
Bundle cost: roughly 85 KB minified UTF-8 (the array literal expansion). The `sideEffects: false` flag on the package plus the `preserveModules: true` Vite output ensure that an import of `@suaveplan/password-generator/password` does not transitively pull in this module — verified by the rollup tree-shaker against the build output.

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EFF_LONG_WORDLIST } from "./eff-long.js";
describe("EFF_LONG_WORDLIST", () => {
it("contains exactly 7776 entries", { timeout: 5000 }, () => {
expect(EFF_LONG_WORDLIST.length).toBe(7776);
});
it("contains no duplicates", { timeout: 5000 }, () => {
expect(new Set(EFF_LONG_WORDLIST).size).toBe(EFF_LONG_WORDLIST.length);
});
it("contains only ASCII letters", { timeout: 30000 }, () => {
for (const word of EFF_LONG_WORDLIST) {
expect(/^[a-z-]+$/.test(word)).toBe(true);
// ASCII-only — no characters above 0x7f
for (let i = 0; i < word.length; i++) {
expect(word.charCodeAt(i)).toBeLessThan(128);
}
}
});
it("starts with 'abacus' (sentinel)", { timeout: 5000 }, () => {
expect(EFF_LONG_WORDLIST[0]).toBe("abacus");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
# `eff-short` — EFF Short Wordlist 1
## Purpose
Bundles EFF's Short Wordlist #1 — 1296 entries (4-dice / 6⁴), ≈ 10.34 bits/word, every entry ≤ 5 ASCII characters. Source: https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt (Electronic Frontier Foundation, released under CC BY 3.0 US). The short list is optimised for memorability and typing speed at the cost of fewer bits per word; an 8-word short-list passphrase (~82.7 bits) is comparable in entropy to a 6.4-word long-list passphrase but markedly faster to type.
Exposed via the registry name `"eff-short"` and the dedicated subpath `@suaveplan/password-generator/wordlists/eff-short` so a consumer who only wants the short list does not pull the ~85 KB long list.
## Features
- Exactly 1296 ASCII entries.
- Every entry is ≤ 5 characters; words are easy to spell.
- Deduplicated; only `[a-z-]` characters.
- Frozen array.
## Usage: Basic Example
```ts
import { EFF_SHORT_WORDLIST } from "@suaveplan/password-generator/wordlists/eff-short";
console.log(EFF_SHORT_WORDLIST.length); // 1296
```
## Usage: Advanced Example
```ts
import { generatePassphrase } from "@suaveplan/password-generator";
// 8 short words for ≈ 82.7 bits — comparable to a 6-word EFF Long passphrase
const pp = generatePassphrase({ wordlist: "short", length: 8 });
```
## API Reference
### `EFF_SHORT_WORDLIST: readonly string[]`
Frozen array of 1296 lowercase ASCII strings.
## Implementation Notes
The short list was constructed by EFF specifically for "shorter words that are easier to type". Per-word entropy is `log2(1296) ≈ 10.34` bits versus `log2(7776) ≈ 12.92` for the long list — to match a 6-word long-list passphrase's 77.55 bits, callers need 7.5 (round up to 8) short-list words. The trade-off favours short-list passphrases when the user must type the phrase on a constrained surface like a mobile virtual keyboard, where the per-word typing-time penalty dominates the per-word entropy gain.
EFF also publishes a Short Wordlist 2 with autocompleting prefixes; this package intentionally bundles only Wordlist 1 to keep the runtime surface minimal. Wordlist 2 is available as a caller-registered list via `registerWordlist`; future versions of this package may add a dedicated subpath if there is demand.

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EFF_SHORT_WORDLIST } from "./eff-short.js";
describe("EFF_SHORT_WORDLIST", () => {
it("contains exactly 1296 entries", { timeout: 5000 }, () => {
expect(EFF_SHORT_WORDLIST.length).toBe(1296);
});
it("contains no duplicates", { timeout: 5000 }, () => {
expect(new Set(EFF_SHORT_WORDLIST).size).toBe(
EFF_SHORT_WORDLIST.length
);
});
it("contains only ASCII letters", { timeout: 5000 }, () => {
for (const word of EFF_SHORT_WORDLIST) {
expect(/^[a-z-]+$/.test(word)).toBe(true);
for (let i = 0; i < word.length; i++) {
expect(word.charCodeAt(i)).toBeLessThan(128);
}
}
});
it("starts with 'acid' (sentinel)", { timeout: 5000 }, () => {
expect(EFF_SHORT_WORDLIST[0]).toBe("acid");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
export { EFF_LONG_WORDLIST } from "./eff-long.js";
export { EFF_SHORT_WORDLIST } from "./eff-short.js";
export {
getWordlist,
listWordlists,
MIN_WORDLIST_SIZE,
registerWordlist,
} from "./registry.js";

View File

@@ -0,0 +1,66 @@
# `wordlists/registry`
## Purpose
The wordlist registry exposes a small plugin API — `registerWordlist`, `getWordlist`, `listWordlists` — that lets callers add localised or themed wordlists to the same name-space the bundled EFF lists use. `generatePassphrase` resolves `opts.wordlist` through this registry, so any caller-registered list gets the same treatment as the built-in lists with zero changes to the passphrase generator. The registry enforces a 256-entry minimum (8 bits per word) so a 16-word passphrase built from any registered list crosses the 128-bit symmetric-key floor.
The registry is pre-populated with `"eff-long"` and `"eff-short"`. Calling `registerWordlist("eff-long", customList)` overwrites the bundled list — useful for caller-supplied localised variants of the EFF lists.
## Features
- Pre-populated with `"eff-long"` and `"eff-short"`.
- 256-entry minimum enforced at registration.
- Unknown names throw a typed `PWGEN.WORDLIST_NOT_REGISTERED` error.
- Re-registration overwrites the previous entry (deliberate, for localisation).
- `listWordlists()` for UI surfaces that need to render available options.
## Usage: Basic Example
```ts
import {
getWordlist,
listWordlists,
registerWordlist,
} from "@suaveplan/password-generator/wordlists/registry";
const dnd = [/* ... 300 fantasy nouns ... */];
registerWordlist("dnd-spells", dnd);
console.log(listWordlists()); // ["eff-long", "eff-short", "dnd-spells"]
```
## Usage: Advanced Example
```ts
import { generatePassphrase } from "@suaveplan/password-generator";
import { registerWordlist } from "@suaveplan/password-generator/wordlists/registry";
// Localise the long list to Italian (must still be ≥ 256 entries; EFF
// publishes localised lists at https://www.eff.org/dice).
const italianLong = await loadItalianEffLong();
registerWordlist("eff-long-it", italianLong);
const pp = generatePassphrase({ wordlist: "eff-long-it", length: 6 });
```
## API Reference
### `registerWordlist(name: string, words: readonly string[]): void`
Throws `PwgenError(PWGEN.WORDLIST_TOO_SHORT)` when `words.length < 256` (the `MIN_WORDLIST_SIZE` constant).
### `getWordlist(name: string): readonly string[]`
Throws `PwgenError(PWGEN.WORDLIST_NOT_REGISTERED)` for unknown names.
### `listWordlists(): string[]`
Returns every registered name, in insertion order.
### `MIN_WORDLIST_SIZE: 256`
Exported so callers can show "needs at least 256 entries" in upload UIs.
## Implementation Notes
The registry is a single module-level `Map<string, readonly string[]>` keyed by name. There is intentionally no per-realm or per-call-site isolation — registration is global because the use case (localising the bundled lists, adding a themed list) is fundamentally a deployment-time decision, not per-request state. Callers that need stricter isolation should pass an inline `string[]` to `generatePassphrase` instead, which bypasses the registry entirely.
The 256-entry floor (`log2(256) = 8` bits/word) is set so that 10-word passphrases against the smallest acceptable list still reach 80 bits, which is above NIST SP 800-63B's symmetric-key-equivalent floor for moderate-strength contexts. EFF's Short Wordlist 1 (1296 entries) comfortably exceeds this — the floor is enforced primarily to protect against caller-supplied "wordlists" that are actually tiny themed lists too short to provide meaningful entropy.

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getWordlist,
listWordlists,
MIN_WORDLIST_SIZE,
registerWordlist,
} from "./registry.js";
describe("wordlist registry", () => {
it("pre-populates eff-long and eff-short", { timeout: 5000 }, () => {
const names = listWordlists();
expect(names.includes("eff-long")).toBe(true);
expect(names.includes("eff-short")).toBe(true);
});
it("registers a wordlist and retrieves it", { timeout: 5000 }, () => {
const words = Array.from({ length: 256 }, (_, i) => `w${i}`);
registerWordlist("dnd-spells", words);
expect(getWordlist("dnd-spells")).toEqual(words);
});
it("rejects wordlist below 256 entries", { timeout: 5000 }, () => {
expect(() => registerWordlist("tiny", ["one", "two"])).toThrow(
/PWGEN\.WORDLIST_TOO_SHORT/
);
});
it("throws PWGEN.WORDLIST_NOT_REGISTERED for unknown names", {
timeout: 5000,
}, () => {
expect(() => getWordlist("never-registered")).toThrow(
/PWGEN\.WORDLIST_NOT_REGISTERED/
);
});
it("MIN_WORDLIST_SIZE is 256", { timeout: 5000 }, () => {
expect(MIN_WORDLIST_SIZE).toBe(256);
});
it("listWordlists returns an array", { timeout: 5000 }, () => {
expect(Array.isArray(listWordlists())).toBe(true);
});
});

View File

@@ -0,0 +1,55 @@
import { pwgenError } from "../errors/codes.js";
import { EFF_LONG_WORDLIST } from "./eff-long.js";
import { EFF_SHORT_WORDLIST } from "./eff-short.js";
/**
* Floor below which a registered wordlist is rejected. 256 entries
* (8 bits per word) is the minimum that makes a 16-word passphrase
* cross the 128-bit symmetric-key floor.
*/
export const MIN_WORDLIST_SIZE = 256;
const registry = new Map<string, readonly string[]>([
["eff-long", EFF_LONG_WORDLIST],
["eff-short", EFF_SHORT_WORDLIST],
]);
/**
* Register a caller-supplied wordlist under `name`. Subsequent calls
* with the same name overwrite. Re-registering `"eff-long"` or
* `"eff-short"` is permitted — callers occasionally want to override
* the bundled lists with a localised variant.
*
* @throws PwgenError(PWGEN.WORDLIST_TOO_SHORT) if `words.length <
* MIN_WORDLIST_SIZE` (256).
*/
export function registerWordlist(name: string, words: readonly string[]): void {
if (words.length < MIN_WORDLIST_SIZE) {
throw pwgenError(
"PWGEN.WORDLIST_TOO_SHORT",
`wordlist "${name}" has ${words.length} entries; minimum is ${MIN_WORDLIST_SIZE}`
);
}
registry.set(name, words);
}
/**
* Return the registered wordlist for `name`.
*
* @throws PwgenError(PWGEN.WORDLIST_NOT_REGISTERED) for unknown names.
*/
export function getWordlist(name: string): readonly string[] {
const list = registry.get(name);
if (!list) {
throw pwgenError(
"PWGEN.WORDLIST_NOT_REGISTERED",
`no wordlist registered under name "${name}"`
);
}
return list;
}
/** Return every registered wordlist name. */
export function listWordlists(): string[] {
return [...registry.keys()];
}