Files
suaveplan/openspec/specs/password-generator/spec.md
2026-05-27 23:29:53 +01:00

406 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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 -->