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

158 lines
16 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.
# `@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.