406 lines
25 KiB
Markdown
406 lines
25 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
||
|
||
# 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<string>` 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"`
|
||
|
||
<!-- markdownlint-enable MD024 -->
|