Files
suaveplan/openspec/archive/2026-05-10-enterprise-password-generator/proposal.md
2026-05-27 23:29:53 +01:00

16 KiB
Raw Blame History

@suaveplan/password-generator — cryptographically-strong password and passphrase generator

Package: @suaveplan/password-generator (NEW) Status: PROPOSED Type: new-package (greenfield migration into genesis from enterprise/password-generator.md) Date: 2026-05-10 Path: packages/vault/password-generator/ Tier: universal (zero browser / Node / React deps)

Why

The vault item editor in @suaveplan/vault exposes a "Generate password" button. That UX requires two cryptographically-strong generation modes — character-class passwords and EFF Diceware passphrases — plus a real entropy estimate so the picker can show "this would take N years to crack" without the surrounding @suaveplan/password-strength package. Today no genesis package owns this surface, so vault would either bake the generator inline (violating package boundaries and re-implementing bias-free RNG) or pull a third-party generator with unknown audit history. This package owns the surface end-to-end, ships with the EFF Long and Short word lists pre-bundled and tree-shakeable, and exposes an injectable Rng so the calling UI can re-roll deterministically when the user holds shift-click without surprises.

The cryptographic contract is non-negotiable: Math.floor(rng() * max) is biased for non-power-of-two max (the very common case for 7776-entry wordlists), so the package implements rejection sampling per Daniel Lemire's Fast Random Integer Generation in an Interval (https://arxiv.org/abs/1805.10941, §3 — "Nearly Divisionless"). NIST SP 800-63B §5.1.1.2 deprecates required-class composition rules, but downstream sites still mandate them, so the generator supports per-class quotas for compatibility while documenting that they reduce effective entropy.

What changes

New package: @suaveplan/password-generator

  • ADDED: packages/vault/password-generator/ package scaffold (package.json, tsconfig.lib.json, vite.config.ts, vitest.config.ts, README.md, src/__benchmarks__/).
  • ADDED: src/random/random-int.ts — randomInt(rng: Rng, max: number): number bias-free uniform integer in [0, max) via Lemire-style rejection sampling.
  • ADDED: src/random/fisher-yates.ts — shuffleInPlace(array, rng) Fisher-Yates shuffle parameterised over the bias-free randomInt.
  • ADDED: src/charsets/charsets.ts — LOWERCASE, UPPERCASE, DIGITS, SYMBOLS, AMBIGUOUS, LOOKALIKES exposed as ReadonlySet<string>; buildEffectiveCharset(opts) applies inclusion + exclusion to produce the union charset.
  • ADDED: src/password/password.ts — generatePassword(opts: GeneratePasswordOptions, rng?: Rng): string. Required-class quotas implemented as "place one char per required class first, fill remainder, Fisher-Yates shuffle the result".
  • ADDED: src/password/password.types.ts — GeneratePasswordOptions, PasswordMode ("random" | "pronounceable"), RequiredClasses interface.
  • ADDED: src/password/pronounceable.ts — Markov-digram CVC syllable generator, English-trained digram table baked in. generatePronounceablePassword(opts, rng?).
  • ADDED: src/passphrase/passphrase.ts — generatePassphrase(opts: GeneratePassphraseOptions, rng?: Rng): string. Capitalisation modes none | firstChar | randomWord, optional addDigits/addSymbols insertions, configurable separator (default -).
  • ADDED: src/passphrase/passphrase.types.ts — GeneratePassphraseOptions, CapitalizationMode, WordlistRef ("long" | "short" | string[]).
  • ADDED: src/wordlists/registry.ts — registerWordlist(name, words[]), getWordlist(name), listWordlists(). Min-size check >= 256 throwing PWGEN.WORDLIST_TOO_SHORT.
  • ADDED: src/wordlists/eff-long.ts + sibling ./wordlists/eff-long subpath — EFF Long Wordlist (7776 entries, 2¹³ ≈ 12.9 bits/word).
  • ADDED: src/wordlists/eff-short.ts + sibling ./wordlists/eff-short subpath — EFF Short Wordlist 1 (1296 entries, 2¹⁰·³⁴ ≈ 10.3 bits/word).
  • ADDED: src/pin/pin.ts — generatePin(length: number, rng?: Rng): string. Bias-free uniform 0–9 over length digits.
  • ADDED: src/entropy/entropy.ts — estimateEntropy(password, opts?): { bits: number; charsetSize: number }, estimatePassphraseEntropy(words: string[], wordlistSize: number): number, assertStrength(password, minBits, opts?): void.
  • ADDED: src/errors/codes.ts — defineCodes("PWGEN", [...]) registry: PWGEN.INVALID_OPTIONS, PWGEN.OPTIONS_INFEASIBLE, PWGEN.WORDLIST_TOO_SHORT, PWGEN.WORDLIST_NOT_REGISTERED, PWGEN.STRENGTH_INSUFFICIENT, PWGEN.RNG_FAILED.
  • ADDED: src/schemas/schemas.ts — Zod GeneratePasswordOptionsSchema, GeneratePassphraseOptionsSchema, EntropyOptionsSchema. Boundary mismatch → PWGEN.INVALID_OPTIONS.
  • ADDED: src/observability/metrics.ts — pwgen.generate_duration_ms{mode} histogram, pwgen.entropy_bits{mode} histogram, pwgen.errors_total{code} counter.
  • ADDED: src/index.ts — explicit named re-exports of every public symbol.

Subpath exports

  • . — root barrel.
  • ./password — char-class + pronounceable generator + types.
  • ./passphrase — passphrase generator + types.
  • ./pin — PIN generator.
  • ./entropy — entropy + strength assertion.
  • ./wordlists/eff-long — EFF Long Wordlist (7776 entries) only.
  • ./wordlists/eff-short — EFF Short Wordlist 1 (1296 entries) only.
  • ./wordlists/registry — plugin API (registerWordlist/getWordlist/listWordlists).
  • ./random — randomInt + shuffleInPlace.
  • ./errors — error code registry.
  • ./schemas — Zod boundary schemas.

Tree-shakeability is the load-bearing goal: a consumer that only wants char-class passwords does not pull the 7776-entry Long Wordlist (~85 KB UTF-8) into its bundle; a consumer that only wants PINs pulls neither.

Dependencies

  • @suaveplan/types workspace:^ — Rng interface (canonical).
  • @suaveplan/error workspace:^ — defineCodes, SuaveplanError.
  • @suaveplan/crypto-utils workspace:^ — randomBytes for the default Rng implementation when caller does not supply one.
  • @opentelemetry/api catalog:observability — histogram + counter emission.
  • tslib catalog:build.
  • zod catalog:forms — boundary schemas.

No dep on @suaveplan/crypto, @suaveplan/crypto-curve, @suaveplan/crypto-cipher, or any browser/Node API. The package is universal-tier and stays that way.

Out of scope

  • Deeper strength scoring (zxcvbn-style) — lives in the separate @suaveplan/password-strength package (already listed in STATUS.md pending). This package's estimateEntropy is an upper-bound log2(charset) * length estimate; pattern-aware scoring is password-strength's job.
  • CLDR / Unicode aware case mapping — capitalisation uses ASCII toUpperCase/toLowerCase because the bundled wordlists are ASCII-only EFF lists. Locale-sensitive case mapping arrives only when a non-ASCII wordlist is registered, and is deferred to a follow-up tied to that wordlist.
  • Pronounceable mode for non-English locales — the bundled digram table is English-trained; multilingual digram tables are a separate change.
  • HIBP (Have I Been Pwned) k-anonymity check — server round-trip, not a generator concern; consumer can wire it after generatePassword returns.
  • Memorability scoring — subjective and out of scope.
  • WebAuthn / passkey generation — entirely different surface owned by @suaveplan/webauthn.
  • Rate-limit / lockout — call-site concern; the generator is pure given Rng + options.

Source mapping

This change derives from ../suaveplan/enterprise/password-generator.md (greenfield migration — no upstream code yet).

Requirement Source lines Notes
Char-class generator (length, classes, exclusions, required-class quotas) enterprise/password-generator.md L13-L14, L30-L36 Verbatim contract; requireLowercase/requireUppercase/requireDigits/requireSymbols flags
Required-class placement + Fisher-Yates shuffle enterprise/password-generator.md L33 Verbatim algorithm description
Bias-free randomInt(rng, max) via rejection sampling enterprise/password-generator.md L24-L24, L36 + Lemire 2018 §3 ("Nearly Divisionless") Rejection sampling against threshold 2^32 - (2^32 mod n)
Reject infeasible options enterprise/password-generator.md L36 PWGEN.OPTIONS_INFEASIBLE for length < requiredCount, empty effective charset
Passphrase generator over EFF wordlists enterprise/password-generator.md L15, L40-L42 Default EFF Long; wordlist: "long" | "short" | string[]
EFF Long Wordlist (7776 entries, 12.9 bits/word) https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases — "Our Long List" anchor Bundled as ASCII array under ./wordlists/eff-long subpath
EFF Short Wordlist 1 (1296 entries, 10.3 bits/word) https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases — "Short Wordlist #1" anchor Bundled under ./wordlists/eff-short subpath
Subpath exports for tree-shaking enterprise/password-generator.md L42 Each wordlist independently importable
Configurable separator, capitalisation mode, addDigits, addSymbols enterprise/password-generator.md L41 Defaults separator: "-", capitalize: "none"
estimateEntropy(password) enterprise/password-generator.md L46-L46 Detect class membership → log2(charsetSize) * length
estimatePassphraseEntropy(words, wordlistSize) enterprise/password-generator.md L47 log2(wordlistSize) * length
Pronounceable Markov-digram mode enterprise/password-generator.md L65 English digram table; CVC syllables
PIN mode enterprise/password-generator.md L66 generatePin(length, rng?); uniform 0–9 via bias-free sampling
Wordlist plugin API + min-size 256 enterprise/password-generator.md L67 PWGEN.WORDLIST_TOO_SHORT
assertStrength(password, minBits) enterprise/password-generator.md L68 Throws PWGEN.STRENGTH_INSUFFICIENT if entropy short
Injectable Rng (default = @suaveplan/crypto-utils.randomBytes) enterprise/password-generator.md L52 Never Math.random() at module scope
Zod boundary schemas enterprise/password-generator.md L53 Mismatch → PWGEN.INVALID_OPTIONS
Error registry enterprise/password-generator.md L54 6 codes minimum (STRENGTH_INSUFFICIENT, WORDLIST_NOT_REGISTERED added beyond the source four)
OTel histograms + error counter enterprise/password-generator.md L55 pwgen.generate_duration_ms{mode}, pwgen.entropy_bits{mode}, pwgen.errors_total{code}
Property test: required-class quotas always satisfied enterprise/password-generator.md L57 fast-check property suite
Property test: passphrase word count + wordlist membership enterprise/password-generator.md L58 fast-check property suite
Statistical test: 1M randomInt(rng, 7776) calls, χ² p > 0.01 enterprise/password-generator.md L59 Reject biased RNG implementations
Determinism: same seed → identical output enterprise/password-generator.md L60 Deterministic Rng injectable for tests
NIST SP 800-63B §5.1.1.2 — composition guidance https://pages.nist.gov/800-63-3/sp800-63b.html#5112-memorized-secret-verifiers Supported but documented as low-value
Mitata benchmarks enterprise/password-generator.md L69 gen_password_16, gen_passphrase_5, gen_passphrase_7, entropy_estimate_long_password

Requirements with no source line

Requirement Justification
PWGEN.WORDLIST_NOT_REGISTERED error code Implied by the plugin API: getWordlist("dnd-spells") must reject unknown names with a typed error. The source lists four codes; this is the inverse path of WORDLIST_TOO_SHORT.
PWGEN.STRENGTH_INSUFFICIENT error code Required by assertStrength (L68). Source enumerates four codes but the strength assertion needs its own typed code so callers can try/catch precisely.
Subpath ./wordlists/registry for plugin API Matches §6 of openspec/AGENTS.md: every feature module gets its own subpath export.
WordlistRef = "long" | "short" | string[] discriminated alias Mentioned in the source as wordlist: "long" | "short" | string[] (L40); promoted to a named type per CLAUDE.md type-design rule (§11 of AGENTS — no inline shapes).

Verification approach

Each requirement carries a **Verified by:** line in the spec delta. Test files:

  • src/random/random-int.test.ts — uniform-distribution + rejection-loop coverage.
  • src/random/random-int.spec-conformance.test.ts — 1M-sample χ² statistical test against n = 7776.
  • src/random/fisher-yates.test.ts — permutation correctness, RNG injectability.
  • src/charsets/charsets.test.ts — set membership, exclusion arithmetic, ambiguous-char filter.
  • src/password/password.test.ts — happy path, exclusion sets, every required-class combination.
  • src/password/password.spec-conformance.test.ts — fast-check property: every generated password satisfies declared required-class quotas; deterministic-seed reproducibility.
  • src/password/pronounceable.test.ts — pronounceable output respects CVC pattern; RNG injectable.
  • src/passphrase/passphrase.test.ts — word count, separator, capitalisation modes, addDigits/addSymbols insertion sites.
  • src/passphrase/passphrase.spec-conformance.test.ts — fast-check property: every word in output is a member of the resolved wordlist; word count equals length.
  • src/wordlists/registry.test.ts — register/get/list happy path, min-size rejection, unknown-name rejection.
  • src/wordlists/eff-long.test.ts — exact-length 7776 invariant; ASCII-only invariant; no duplicates.
  • src/wordlists/eff-short.test.ts — exact-length 1296 invariant; ASCII-only invariant; no duplicates.
  • src/pin/pin.test.ts — every char in [0-9]; length matches.
  • src/pin/pin.spec-conformance.test.ts — bias-free distribution check against n = 10.
  • src/entropy/entropy.test.ts — class-detection table, log2 math, assertStrength throw path.
  • src/errors/codes.test.ts — every code thrown by ≥1 production call site (validates §2c of AGENTS).
  • src/schemas/schemas.test.ts — every schema rejects malformed input with PWGEN.INVALID_OPTIONS.
  • src/observability/metrics.test.ts — every histogram + counter emitted from a real MeterProvider.
  • src/__benchmarks__/password.bench.ts — mitata benches gen_password_16, gen_passphrase_5, gen_passphrase_7, entropy_estimate_long_password.

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/password-generator to enumerate after the package lands. At proposal time the only forward-looking consumer is @suaveplan/vault ("Generate password" button), which is itself in flight — therefore:

  • @suaveplan/vault — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: vault consumes this package only after both packages graduate; vault's own change set will wire the import.)

No upstream code currently imports a generator from suaveplan that this package replaces — verify by grep -rn "password-generator\|generatePassword\|generatePassphrase" /mnt/s/web/suaveplan/packages/ 2>/dev/null before the package lands; if a hit exists, that consumer migrates as part of its own change.

Migration

Greenfield package — no migration. Future consumers should:

  • Import the smallest subpath they need (./password for char-class only, ./passphrase for diceware only, ./wordlists/eff-long if they wire their own passphrase loop).
  • Pass an injectable Rng from @suaveplan/crypto-utils.randomBytes in production; tests may use a deterministic seeded Rng for reproducibility.
  • Use assertStrength(password, minBits) as a server-side guard before persisting a generated password; the entropy floor for vault items SHOULD be ≥ 80 bits.