# Capability Spec: password-generator **Package**: `@suaveplan/password-generator` **Status**: ACTIVE > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## Requirements ### Requirement: Char-class password generator The package SHALL export a `generatePassword(opts: GeneratePasswordOptions, rng?: Rng): string` function that returns a `length`-character password drawn uniformly from the union of enabled character classes (`lowercase`, `uppercase`, `digits`, `symbols`) minus any caller-supplied exclusions. Required-class quotas (`requireLowercase`, `requireUppercase`, `requireDigits`, `requireSymbols`) SHALL be satisfied by placing one randomly-chosen character from each required class first, filling the remainder from the union, and then Fisher-Yates-shuffling the buffer so the required-class characters are not biased to the head. The character classes SHALL be exposed as `ReadonlySet` constants (`LOWERCASE`, `UPPERCASE`, `DIGITS`, `SYMBOLS`, `AMBIGUOUS`, `LOOKALIKES`) so callers can author custom unions. #### Scenario: generates a length-16 password from the default union - **WHEN** `generatePassword({ length: 16 })` is called with the default `Rng` - **THEN** the result is a 16-character string containing only characters from `LOWERCASE ∪ UPPERCASE ∪ DIGITS ∪ SYMBOLS` #### Scenario: respects exclusion sets - **WHEN** `generatePassword({ length: 32, exclude: AMBIGUOUS })` is called - **THEN** the result contains zero characters from the `AMBIGUOUS` set (`0`, `O`, `l`, `1`, `I`) #### Scenario: required-class quotas all satisfied - **WHEN** `generatePassword({ length: 12, requireLowercase: true, requireUppercase: true, requireDigits: true, requireSymbols: true })` is called - **THEN** the result contains at least one character from each of `LOWERCASE`, `UPPERCASE`, `DIGITS`, `SYMBOLS` #### Scenario: required-class character is not biased to position 0 - **GIVEN** 1000 generated passwords with `requireDigits: true` - **WHEN** the position of the first digit is recorded - **THEN** the empirical distribution of first-digit-position is approximately uniform across `[0, length)` (no spike at index 0) **Verified by:** `src/password/password.test.ts::"generates a length-16 password from the default union"` AND `src/password/password.test.ts::"respects exclusion sets"` AND `src/password/password.spec-conformance.test.ts::"every required class is present in the output"` AND `src/password/password.spec-conformance.test.ts::"required-class character position is uniform after shuffle"` --- ### Requirement: Bias-free uniform random integer The package SHALL export `randomInt(rng: Rng, max: number): number` that returns a uniformly-distributed integer in the half-open interval `[0, max)` for any `max ∈ [1, 2^32)`. The implementation SHALL use rejection sampling per Lemire 2018 _Fast Random Integer Generation in an Interval_ §3 ("Nearly Divisionless"), rejecting drawn values above the threshold `2^32 - (2^32 mod max)` and re-drawing from `rng`. The implementation SHALL NOT use `Math.floor(rng() * max)` (biased for non-power-of-two `max`). #### Scenario: returns a value in the requested range - **WHEN** `randomInt(rng, 7776)` is called for any seeded `Rng` - **THEN** the result is a non-negative integer strictly less than `7776` #### Scenario: triggers the rejection branch when the draw exceeds the threshold - **GIVEN** an `Rng` whose first call returns a uint32 above `2^32 - (2^32 mod 7776)` and whose second call returns a uint32 below the threshold - **WHEN** `randomInt(rng, 7776)` is called - **THEN** the function calls `rng` exactly twice and returns the second draw modulo `7776` #### Scenario: rejects pathological RNG via PWGEN.RNG_FAILED - **GIVEN** an `Rng` that always returns the same out-of-range uint32 - **WHEN** `randomInt(rng, 7776)` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.RNG_FAILED"` after a bounded retry budget rather than looping forever **Verified by:** `src/random/random-int.test.ts::"returns value in [0, max)"` AND `src/random/random-int.test.ts::"triggers rejection branch above threshold"` AND `src/random/random-int.test.ts::"throws PWGEN.RNG_FAILED on pathological RNG"` --- ### Requirement: Reject infeasible password options The package SHALL throw `SuaveplanError("PWGEN.OPTIONS_INFEASIBLE")` when `generatePassword` is called with options that cannot produce a valid output: when `length < requiredCount` (the sum of `requireLowercase + requireUppercase + requireDigits + requireSymbols`), or when the effective charset (union of enabled classes minus exclusions) is empty. #### Scenario: length below required count throws - **WHEN** `generatePassword({ length: 2, requireLowercase: true, requireUppercase: true, requireDigits: true, requireSymbols: true })` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.OPTIONS_INFEASIBLE"` #### Scenario: empty effective charset throws - **WHEN** `generatePassword({ length: 8, lowercase: false, uppercase: false, digits: false, symbols: false })` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.OPTIONS_INFEASIBLE"` **Verified by:** `src/password/password.test.ts::"throws PWGEN.OPTIONS_INFEASIBLE when length below required count"` AND `src/password/password.test.ts::"throws PWGEN.OPTIONS_INFEASIBLE when effective charset is empty"` --- ### Requirement: Passphrase generator over EFF wordlists The package SHALL export a `generatePassphrase(opts: GeneratePassphraseOptions, rng?: Rng): string` function that produces a passphrase by drawing `length` words uniformly (via `randomInt`) from the wordlist resolved via `opts.wordlist`. `opts.wordlist` SHALL accept `"long"` (resolves to the bundled EFF Long Wordlist, default), `"short"` (resolves to the bundled EFF Short Wordlist 1), or a `string[]` (caller-supplied wordlist passed through the registry's min-size check). #### Scenario: default wordlist is EFF Long with default length 6 - **WHEN** `generatePassphrase({})` is called - **THEN** the result splits on `-` into 6 strings, every one of which is a member of the EFF Long Wordlist #### Scenario: short-wordlist passphrase - **WHEN** `generatePassphrase({ wordlist: "short", length: 7 })` is called - **THEN** the result splits on `-` into 7 strings, every one of which is a member of the EFF Short Wordlist 1 #### Scenario: caller-supplied wordlist passes through the registry guard - **WHEN** `generatePassphrase({ wordlist: ["alpha", ...255 more words], length: 4 })` is called with a 256-entry array - **THEN** the result is a 4-word passphrase drawn uniformly from the supplied array **Verified by:** `src/passphrase/passphrase.test.ts::"defaults to EFF Long with length 6"` AND `src/passphrase/passphrase.test.ts::"draws from EFF Short when wordlist is short"` AND `src/passphrase/passphrase.spec-conformance.test.ts::"every output word is a member of the resolved wordlist"` --- ### Requirement: Bundled tree-shakeable wordlists via subpath imports The package SHALL bundle the EFF Long Wordlist and the EFF Short Wordlist 1 as ASCII-only deduplicated `readonly string[]` arrays accessible via separate subpath exports — `@suaveplan/password-generator/wordlists/eff-long` and `@suaveplan/password-generator/wordlists/eff-short` — so a consumer that wants only one wordlist does not pay the bundle cost of both. The Long list SHALL contain exactly 7776 entries; the Short list SHALL contain exactly 1296 entries. #### Scenario: EFF Long Wordlist length invariant - **WHEN** `EFF_LONG_WORDLIST.length` is read - **THEN** the value equals `7776` #### Scenario: EFF Short Wordlist 1 length invariant - **WHEN** `EFF_SHORT_WORDLIST.length` is read - **THEN** the value equals `1296` #### Scenario: wordlists contain no duplicates - **WHEN** each wordlist is loaded into a `Set` - **THEN** the set size equals the array length **Verified by:** `src/wordlists/eff-long.test.ts::"contains exactly 7776 entries"` AND `src/wordlists/eff-long.test.ts::"contains no duplicates"` AND `src/wordlists/eff-short.test.ts::"contains exactly 1296 entries"` AND `src/wordlists/eff-short.test.ts::"contains no duplicates"` --- ### Requirement: Passphrase configuration — separator, capitalisation, decoration The package SHALL accept `separator` (default `-`), `capitalize` (`"none" | "firstChar" | "randomWord"`, default `"none"`), `addDigits` (default `0`), and `addSymbols` (default `0`) on `GeneratePassphraseOptions`. `capitalize: "firstChar"` SHALL upper-case the first character of every word; `capitalize: "randomWord"` SHALL upper-case the first character of exactly one randomly-chosen word. `addDigits` and `addSymbols` SHALL append the requested count of randomly-chosen characters drawn via `randomInt` from `DIGITS` and `SYMBOLS` respectively, inserted at randomised positions in the joined passphrase. #### Scenario: capitalize firstChar applies to every word - **WHEN** `generatePassphrase({ length: 4, capitalize: "firstChar" })` is called - **THEN** every word in the result begins with an upper-case ASCII letter #### Scenario: capitalize randomWord applies to exactly one word - **WHEN** `generatePassphrase({ length: 4, capitalize: "randomWord" })` is called for a deterministic seeded `Rng` - **THEN** exactly one word in the result begins with an upper-case ASCII letter #### Scenario: addDigits and addSymbols inject the requested counts - **WHEN** `generatePassphrase({ length: 3, addDigits: 2, addSymbols: 1 })` is called - **THEN** the result contains exactly two characters from `DIGITS` and exactly one character from `SYMBOLS` **Verified by:** `src/passphrase/passphrase.test.ts::"capitalize firstChar applies to every word"` AND `src/passphrase/passphrase.test.ts::"capitalize randomWord applies to exactly one word"` AND `src/passphrase/passphrase.test.ts::"addDigits and addSymbols inject the requested counts"` --- ### Requirement: Char-class entropy estimator The package SHALL export `estimateEntropy(password: string, opts?: EntropyOptions): { bits: number; charsetSize: number }`. The implementation SHALL detect class membership in the password (lowercase, uppercase, digits, symbols), compute `charsetSize` as the cardinality of the union of detected classes, and return `bits = log2(charsetSize) * password.length`. The estimator is documented as an upper-bound approximation; pattern-aware scoring lives in `@suaveplan/password-strength`. #### Scenario: lowercase-only password reports 26-char charset - **WHEN** `estimateEntropy("abcdefgh")` is called - **THEN** the result `charsetSize` equals `26` and `bits` equals `log2(26) * 8` #### Scenario: mixed lowercase + uppercase + digits reports 62-char charset - **WHEN** `estimateEntropy("Abc123Xy")` is called - **THEN** the result `charsetSize` equals `62` and `bits` equals `log2(62) * 8` **Verified by:** `src/entropy/entropy.test.ts::"reports 26-char charset for lowercase-only password"` AND `src/entropy/entropy.test.ts::"reports 62-char charset for lowercase+uppercase+digits"` --- ### Requirement: Passphrase entropy estimator The package SHALL export `estimatePassphraseEntropy(words: string[], wordlistSize: number): number` returning `log2(wordlistSize) * words.length`. The function SHALL NOT inspect the words themselves — the caller is trusted to pass a word array drawn from a wordlist of size `wordlistSize`. #### Scenario: 6-word EFF Long passphrase reports ~77.5 bits - **WHEN** `estimatePassphraseEntropy(words, 7776)` is called for any 6-element `words` - **THEN** the result equals `log2(7776) * 6` (≈ 77.55 bits) #### Scenario: 5-word EFF Short passphrase reports ~51.7 bits - **WHEN** `estimatePassphraseEntropy(words, 1296)` is called for any 5-element `words` - **THEN** the result equals `log2(1296) * 5` (≈ 51.7 bits) **Verified by:** `src/entropy/entropy.test.ts::"6-word EFF Long passphrase reports ~77.5 bits"` AND `src/entropy/entropy.test.ts::"5-word EFF Short passphrase reports ~51.7 bits"` --- ### Requirement: Pronounceable mode The package SHALL export `generatePronounceablePassword(opts: GeneratePasswordOptions & { mode: "pronounceable" }, rng?: Rng): string` that emits CVC-pattern syllables driven by an English-trained Markov digram table baked into the package. The output SHALL alternate consonant–vowel–consonant syllables to within `length` characters. The function SHALL document — in its sibling `.md` — that pronounceable output has lower entropy than `generatePassword` and SHOULD be combined with a higher `length` or used only for shoulder-surf-friendly UX contexts. #### Scenario: emits a length-N string of alternating CVC syllables - **WHEN** `generatePronounceablePassword({ length: 12, mode: "pronounceable" })` is called - **THEN** the result is a 12-character string consisting of repeated 3-character syllables matching `[bcdfghjklmnpqrstvwxyz][aeiou][bcdfghjklmnpqrstvwxyz]` #### Scenario: respects injected RNG for determinism - **GIVEN** two calls to `generatePronounceablePassword` with the same seeded `Rng` - **WHEN** both produce a 12-character output - **THEN** the two outputs are identical **Verified by:** `src/password/pronounceable.test.ts::"emits CVC syllables of total length N"` AND `src/password/pronounceable.test.ts::"deterministic under seeded Rng"` --- ### Requirement: PIN mode The package SHALL export `generatePin(length: number, rng?: Rng): string` that returns a `length`-digit string drawn uniformly from `[0-9]` via the bias-free `randomInt(rng, 10)`. The function SHALL throw `PWGEN.OPTIONS_INFEASIBLE` for `length < 1`. #### Scenario: generates a length-6 PIN - **WHEN** `generatePin(6)` is called - **THEN** the result is a 6-character string and every character is in `"0123456789"` #### Scenario: zero-length rejected - **WHEN** `generatePin(0)` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.OPTIONS_INFEASIBLE"` **Verified by:** `src/pin/pin.test.ts::"generates a length-6 PIN"` AND `src/pin/pin.test.ts::"rejects zero length"` --- ### Requirement: Wordlist plugin API with min-size guard The package SHALL export `registerWordlist(name: string, words: readonly string[]): void`, `getWordlist(name: string): readonly string[]`, and `listWordlists(): string[]`. `registerWordlist` SHALL throw `PWGEN.WORDLIST_TOO_SHORT` when `words.length < 256`. `getWordlist` SHALL throw `PWGEN.WORDLIST_NOT_REGISTERED` for unknown names. The registry SHALL be pre-populated with `"eff-long"` and `"eff-short"` so the bundled lists are queryable through the same surface as caller-registered lists. #### Scenario: registers a wordlist and retrieves it - **WHEN** `registerWordlist("dnd-spells", spellArray)` (256 entries) followed by `getWordlist("dnd-spells")` is called - **THEN** the returned array equals `spellArray` #### Scenario: rejects a wordlist below the min-size - **WHEN** `registerWordlist("tiny", ["one","two"])` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.WORDLIST_TOO_SHORT"` #### Scenario: getWordlist throws for unknown names - **WHEN** `getWordlist("never-registered")` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.WORDLIST_NOT_REGISTERED"` **Verified by:** `src/wordlists/registry.test.ts::"registers a wordlist and retrieves it"` AND `src/wordlists/registry.test.ts::"rejects wordlist below 256 entries"` AND `src/wordlists/registry.test.ts::"throws PWGEN.WORDLIST_NOT_REGISTERED for unknown names"` --- ### Requirement: Strength assertion The package SHALL export `assertStrength(password: string, minBits: number, opts?: EntropyOptions): void` that calls `estimateEntropy(password, opts)` and throws `SuaveplanError("PWGEN.STRENGTH_INSUFFICIENT")` when the computed `bits` value is strictly less than `minBits`. The function SHALL return `void` on success — the caller proves strength by absence of throw, not by inspecting a return value. #### Scenario: passes when entropy meets the floor - **WHEN** `assertStrength("Abc123Xy", 40)` is called (≈47.6 bits) - **THEN** the call returns without throwing #### Scenario: throws when entropy is below the floor - **WHEN** `assertStrength("aaaaaaaa", 80)` is called (≈37.6 bits) - **THEN** the call throws `SuaveplanError` with code `"PWGEN.STRENGTH_INSUFFICIENT"` **Verified by:** `src/entropy/entropy.test.ts::"assertStrength passes when entropy meets the floor"` AND `src/entropy/entropy.test.ts::"assertStrength throws when entropy is below the floor"` --- ### Requirement: Injectable Rng — never Math.random at module scope The package SHALL accept an optional `rng: Rng` parameter on every generator function. When omitted, the default `Rng` SHALL come from `@suaveplan/crypto-utils.randomBytes`. The package SHALL contain zero references to `Math.random` in production source. #### Scenario: default Rng is cryptographically strong - **WHEN** `generatePassword({ length: 16 })` is called without an `rng` argument - **THEN** the implementation draws bytes from `@suaveplan/crypto-utils.randomBytes`, NOT `Math.random` #### Scenario: injected Rng is honoured - **GIVEN** a deterministic seeded `Rng` injected into both calls - **WHEN** `generatePassword({ length: 16 }, rng)` is called twice with the same seed - **THEN** the two outputs are identical #### Scenario: source contains zero Math.random references - **WHEN** the source tree is grep'd for `Math\.random` - **THEN** the result is empty **Verified by:** `src/password/password.spec-conformance.test.ts::"deterministic output for a seeded Rng"` AND `src/password/password.spec-conformance.test.ts::"default Rng draws from crypto-utils.randomBytes"` AND `tests/no-math-random.test.ts::"src tree contains zero Math.random references"` --- ### Requirement: Error registry — every code thrown by ≥1 production path The package SHALL declare its error codes via `defineCodes("PWGEN", [...])` from `@suaveplan/error`. The registry SHALL contain at minimum `INVALID_OPTIONS`, `OPTIONS_INFEASIBLE`, `WORDLIST_TOO_SHORT`, `WORDLIST_NOT_REGISTERED`, `STRENGTH_INSUFFICIENT`, `RNG_FAILED`. Every code SHALL be thrown by at least one production call site (orphan registry entries are banned per `openspec/AGENTS.md` §2c). #### Scenario: every registered code has a thrown call site - **WHEN** the test enumerates `PWGEN_CODES` and greps the production source for a `throw` statement raising each code - **THEN** every code has at least one match **Verified by:** `src/errors/codes.test.ts::"every registered code is thrown by at least one production call site"` --- ### Requirement: Zod boundary schemas The package SHALL declare `GeneratePasswordOptionsSchema`, `GeneratePassphraseOptionsSchema`, and `EntropyOptionsSchema` via Zod. Every public generator function SHALL parse its `opts` argument through the matching schema and throw `SuaveplanError("PWGEN.INVALID_OPTIONS")` (with the original `ZodError` attached as `cause`) on schema mismatch. #### Scenario: malformed GeneratePasswordOptions rejected - **WHEN** `generatePassword({ length: -1 })` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.INVALID_OPTIONS"` and `cause` is a `ZodError` #### Scenario: malformed GeneratePassphraseOptions rejected - **WHEN** `generatePassphrase({ length: 0 })` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.INVALID_OPTIONS"` #### Scenario: malformed EntropyOptions rejected - **WHEN** `estimateEntropy("abc", { extraClasses: 42 as never })` is called - **THEN** the call throws `SuaveplanError` with code `"PWGEN.INVALID_OPTIONS"` **Verified by:** `src/schemas/schemas.test.ts::"GeneratePasswordOptionsSchema rejects negative length"` AND `src/schemas/schemas.test.ts::"GeneratePassphraseOptionsSchema rejects zero length"` AND `src/schemas/schemas.test.ts::"EntropyOptionsSchema rejects malformed extraClasses"` --- ### Requirement: OpenTelemetry instrumentation The package SHALL emit `pwgen.generate_duration_ms{mode}` (histogram) and `pwgen.entropy_bits{mode}` (histogram) on every successful generator call, and `pwgen.errors_total{code}` (counter) on every thrown `SuaveplanError`. The `mode` attribute SHALL be one of `"random"`, `"pronounceable"`, `"passphrase"`, `"pin"` (low-cardinality enum). All instruments SHALL be obtained from `metrics.getMeter("@suaveplan/password-generator")`. #### Scenario: histogram emitted on successful password generation - **GIVEN** a real in-memory `MeterProvider` registered globally - **WHEN** `generatePassword({ length: 16 })` is called - **THEN** the captured metrics include a `pwgen.generate_duration_ms` data point with `mode="random"` #### Scenario: counter incremented on infeasible options - **GIVEN** a real in-memory `MeterProvider` registered globally - **WHEN** `generatePassword({ length: 0 })` is called and throws - **THEN** the captured metrics include a `pwgen.errors_total` increment with `code="PWGEN.OPTIONS_INFEASIBLE"` **Verified by:** `src/observability/metrics.test.ts::"emits pwgen.generate_duration_ms histogram for random mode"` AND `src/observability/metrics.test.ts::"increments pwgen.errors_total on PWGEN.OPTIONS_INFEASIBLE"` --- ### Requirement: Property test — required-class quotas always satisfied The package SHALL include a fast-check property test asserting that for any randomly-sampled `(length, requireLowercase, requireUppercase, requireDigits, requireSymbols)` combination satisfying `length >= requiredCount`, the generated password contains at least one character from each required class. The property SHALL be exercised over at least 200 fast-check runs. #### Scenario: every sampled options bundle yields a quota-respecting password - **WHEN** fast-check samples 200 valid options bundles and `generatePassword` is invoked for each - **THEN** every output satisfies all declared required-class quotas **Verified by:** `src/password/password.spec-conformance.test.ts::"every sampled options bundle yields a quota-respecting password"` --- ### Requirement: Property test — passphrase word membership and count The package SHALL include a fast-check property test asserting that for any sampled `(wordlistRef, length)` combination, the generated passphrase splits into exactly `length` words and every word is a member of the resolved wordlist. The property SHALL be exercised over at least 200 fast-check runs. #### Scenario: every sampled passphrase respects the membership invariant - **WHEN** fast-check samples 200 valid options bundles and `generatePassphrase` is invoked for each - **THEN** every output, split on the configured separator, has exactly `length` segments and every segment is a member of the resolved wordlist **Verified by:** `src/passphrase/passphrase.spec-conformance.test.ts::"every sampled passphrase respects the membership invariant"` --- ### Requirement: Statistical distribution test — chi-squared p > 0.01 over 1M draws The package SHALL include a statistical test that performs 1,000,000 calls to `randomInt(rng, 7776)`, bins the results into 7776 buckets, computes the Pearson chi-squared statistic against the expected uniform distribution, and asserts that the resulting p-value exceeds `0.01`. This guards against regressions that re-introduce bias (e.g. accidental `Math.floor(rng() * max)` substitution) which would shift the p-value below the threshold. #### Scenario: bias-free RNG passes the chi-squared threshold - **GIVEN** 1,000,000 `randomInt(rng, 7776)` draws using `@suaveplan/crypto-utils.randomBytes` as the underlying `Rng` - **WHEN** the chi-squared p-value is computed against the uniform distribution - **THEN** the p-value is strictly greater than `0.01` **Verified by:** `src/random/random-int.spec-conformance.test.ts::"1M-sample chi-squared p > 0.01 over n=7776 buckets"` --- ### Requirement: Determinism — same seed yields identical output The package SHALL produce byte-identical output for two invocations of the same generator function passed the same options and the same deterministic seeded `Rng`. This is the contract that lets the calling UI re-render previews, lets tests assert on exact strings, and lets reviewers reproduce regressions from a logged seed. #### Scenario: generatePassword is deterministic under a seeded Rng - **GIVEN** two `Rng` instances built from the same seed - **WHEN** `generatePassword({ length: 32, requireDigits: true }, rng1)` and `generatePassword({ length: 32, requireDigits: true }, rng2)` are called - **THEN** the two return values are equal as strings #### Scenario: generatePassphrase is deterministic under a seeded Rng - **GIVEN** two `Rng` instances built from the same seed - **WHEN** `generatePassphrase({ length: 6, capitalize: "firstChar" }, rng1)` and the same call with `rng2` are run - **THEN** the two return values are equal as strings **Verified by:** `src/password/password.spec-conformance.test.ts::"generatePassword is deterministic under a seeded Rng"` AND `src/passphrase/passphrase.spec-conformance.test.ts::"generatePassphrase is deterministic under a seeded Rng"`