25 KiB
Capability Spec: password-generator
Package: @suaveplan/password-generator
Status: ACTIVE
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 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 defaultRng - 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
AMBIGUOUSset (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 seededRng - 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
Rngwhose first call returns a uint32 above2^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
rngexactly twice and returns the second draw modulo7776
Scenario: rejects pathological RNG via PWGEN.RNG_FAILED
- GIVEN an
Rngthat always returns the same out-of-range uint32 - WHEN
randomInt(rng, 7776)is called - THEN the call throws
SuaveplanErrorwith 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
SuaveplanErrorwith 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
SuaveplanErrorwith 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.lengthis read - THEN the value equals
7776
Scenario: EFF Short Wordlist 1 length invariant
- WHEN
EFF_SHORT_WORDLIST.lengthis 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 seededRng - 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
DIGITSand exactly one character fromSYMBOLS
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
charsetSizeequals26andbitsequalslog2(26) * 8
Scenario: mixed lowercase + uppercase + digits reports 62-char charset
- WHEN
estimateEntropy("Abc123Xy")is called - THEN the result
charsetSizeequals62andbitsequalslog2(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-elementwords - 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-elementwords - 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
generatePronounceablePasswordwith the same seededRng - 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
SuaveplanErrorwith 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 bygetWordlist("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
SuaveplanErrorwith code"PWGEN.WORDLIST_TOO_SHORT"
Scenario: getWordlist throws for unknown names
- WHEN
getWordlist("never-registered")is called - THEN the call throws
SuaveplanErrorwith 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
SuaveplanErrorwith 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 anrngargument - THEN the implementation draws bytes from
@suaveplan/crypto-utils.randomBytes, NOTMath.random
Scenario: injected Rng is honoured
- GIVEN a deterministic seeded
Rnginjected 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_CODESand greps the production source for athrowstatement 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
SuaveplanErrorwith code"PWGEN.INVALID_OPTIONS"andcauseis aZodError
Scenario: malformed GeneratePassphraseOptions rejected
- WHEN
generatePassphrase({ length: 0 })is called - THEN the call throws
SuaveplanErrorwith code"PWGEN.INVALID_OPTIONS"
Scenario: malformed EntropyOptions rejected
- WHEN
estimateEntropy("abc", { extraClasses: 42 as never })is called - THEN the call throws
SuaveplanErrorwith 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
MeterProviderregistered globally - WHEN
generatePassword({ length: 16 })is called - THEN the captured metrics include a
pwgen.generate_duration_msdata point withmode="random"
Scenario: counter incremented on infeasible options
- GIVEN a real in-memory
MeterProviderregistered globally - WHEN
generatePassword({ length: 0 })is called and throws - THEN the captured metrics include a
pwgen.errors_totalincrement withcode="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
generatePasswordis 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
generatePassphraseis invoked for each - THEN every output, split on the configured separator, has exactly
lengthsegments 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.randomBytesas the underlyingRng - 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
Rnginstances built from the same seed - WHEN
generatePassword({ length: 32, requireDigits: true }, rng1)andgeneratePassword({ 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
Rnginstances built from the same seed - WHEN
generatePassphrase({ length: 6, capitalize: "firstChar" }, rng1)and the same call withrng2are 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"