16 KiB
@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): numberbias-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-freerandomInt. - ADDED:
src/charsets/charsets.ts—LOWERCASE,UPPERCASE,DIGITS,SYMBOLS,AMBIGUOUS,LOOKALIKESexposed asReadonlySet<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"),RequiredClassesinterface. - 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 modesnone | firstChar | randomWord, optionaladdDigits/addSymbolsinsertions, 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>= 256throwingPWGEN.WORDLIST_TOO_SHORT. - ADDED:
src/wordlists/eff-long.ts+ sibling./wordlists/eff-longsubpath — EFF Long Wordlist (7776 entries, 2¹³ ≈ 12.9 bits/word). - ADDED:
src/wordlists/eff-short.ts+ sibling./wordlists/eff-shortsubpath — 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 overlengthdigits. - 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— ZodGeneratePasswordOptionsSchema,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:^—Rnginterface (canonical).@suaveplan/error workspace:^—defineCodes,SuaveplanError.@suaveplan/crypto-utils workspace:^—randomBytesfor the defaultRngimplementation 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-strengthpackage (already listed inSTATUS.mdpending). This package'sestimateEntropyis an upper-boundlog2(charset) * lengthestimate; pattern-aware scoring ispassword-strength's job. - CLDR / Unicode aware case mapping — capitalisation uses ASCII
toUpperCase/toLowerCasebecause 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
generatePasswordreturns. - 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 againstn = 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 equalslength.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 againstn = 10.src/entropy/entropy.test.ts— class-detection table, log2 math,assertStrengththrow 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 withPWGEN.INVALID_OPTIONS.src/observability/metrics.test.ts— every histogram + counter emitted from a realMeterProvider.src/__benchmarks__/password.bench.ts— mitata benchesgen_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 (
./passwordfor char-class only,./passphrasefor diceware only,./wordlists/eff-longif they wire their own passphrase loop). - Pass an injectable
Rngfrom@suaveplan/crypto-utils.randomBytesin production; tests may use a deterministic seededRngfor 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.