Initial Commit
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
# `@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.
|
||||
Reference in New Issue
Block a user