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.
|
||||
@@ -0,0 +1,405 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: password-generator (enterprise-password-generator)
|
||||
|
||||
**Package**: `@suaveplan/password-generator`
|
||||
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED 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 default `Rng`
|
||||
- **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 `AMBIGUOUS` set (`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 seeded `Rng`
|
||||
- **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 `Rng` whose first call returns a uint32 above `2^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 `rng` exactly twice and returns the second draw modulo `7776`
|
||||
|
||||
#### Scenario: rejects pathological RNG via PWGEN.RNG_FAILED
|
||||
|
||||
- **GIVEN** an `Rng` that always returns the same out-of-range uint32
|
||||
- **WHEN** `randomInt(rng, 7776)` is called
|
||||
- **THEN** the call throws `SuaveplanError` with 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 `SuaveplanError` with 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 `SuaveplanError` with 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.length` is read
|
||||
- **THEN** the value equals `7776`
|
||||
|
||||
#### Scenario: EFF Short Wordlist 1 length invariant
|
||||
|
||||
- **WHEN** `EFF_SHORT_WORDLIST.length` is 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 seeded `Rng`
|
||||
- **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 `DIGITS` and exactly one character from `SYMBOLS`
|
||||
|
||||
**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 `charsetSize` equals `26` and `bits` equals `log2(26) * 8`
|
||||
|
||||
#### Scenario: mixed lowercase + uppercase + digits reports 62-char charset
|
||||
|
||||
- **WHEN** `estimateEntropy("Abc123Xy")` is called
|
||||
- **THEN** the result `charsetSize` equals `62` and `bits` equals `log2(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-element `words`
|
||||
- **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-element `words`
|
||||
- **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 `generatePronounceablePassword` with the same seeded `Rng`
|
||||
- **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 `SuaveplanError` with 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 by `getWordlist("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 `SuaveplanError` with code `"PWGEN.WORDLIST_TOO_SHORT"`
|
||||
|
||||
#### Scenario: getWordlist throws for unknown names
|
||||
|
||||
- **WHEN** `getWordlist("never-registered")` is called
|
||||
- **THEN** the call throws `SuaveplanError` with 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 `SuaveplanError` with 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 an `rng` argument
|
||||
- **THEN** the implementation draws bytes from `@suaveplan/crypto-utils.randomBytes`, NOT `Math.random`
|
||||
|
||||
#### Scenario: injected Rng is honoured
|
||||
|
||||
- **GIVEN** a deterministic seeded `Rng` injected 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_CODES` and greps the production source for a `throw` statement 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 `SuaveplanError` with code `"PWGEN.INVALID_OPTIONS"` and `cause` is a `ZodError`
|
||||
|
||||
#### Scenario: malformed GeneratePassphraseOptions rejected
|
||||
|
||||
- **WHEN** `generatePassphrase({ length: 0 })` is called
|
||||
- **THEN** the call throws `SuaveplanError` with code `"PWGEN.INVALID_OPTIONS"`
|
||||
|
||||
#### Scenario: malformed EntropyOptions rejected
|
||||
|
||||
- **WHEN** `estimateEntropy("abc", { extraClasses: 42 as never })` is called
|
||||
- **THEN** the call throws `SuaveplanError` with 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 `MeterProvider` registered globally
|
||||
- **WHEN** `generatePassword({ length: 16 })` is called
|
||||
- **THEN** the captured metrics include a `pwgen.generate_duration_ms` data point with `mode="random"`
|
||||
|
||||
#### Scenario: counter incremented on infeasible options
|
||||
|
||||
- **GIVEN** a real in-memory `MeterProvider` registered globally
|
||||
- **WHEN** `generatePassword({ length: 0 })` is called and throws
|
||||
- **THEN** the captured metrics include a `pwgen.errors_total` increment with `code="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 `generatePassword` is 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 `generatePassphrase` is invoked for each
|
||||
- **THEN** every output, split on the configured separator, has exactly `length` segments 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.randomBytes` as the underlying `Rng`
|
||||
- **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 `Rng` instances built from the same seed
|
||||
- **WHEN** `generatePassword({ length: 32, requireDigits: true }, rng1)` and `generatePassword({ 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 `Rng` instances built from the same seed
|
||||
- **WHEN** `generatePassphrase({ length: 6, capitalize: "firstChar" }, rng1)` and the same call with `rng2` are 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,143 @@
|
||||
# Tasks — `@suaveplan/password-generator` (cryptographically-strong password and passphrase generator)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/password-generator.md` end-to-end
|
||||
- [x] 1.2 Read EFF wordlists deep-dive (https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases) — note exact 7776 / 1296 entry counts and bits-per-word
|
||||
- [x] 1.3 Read Lemire 2018 _Fast Random Integer Generation in an Interval_ (https://arxiv.org/abs/1805.10941) §3 ("Nearly Divisionless")
|
||||
- [x] 1.4 Read NIST SP 800-63B §5.1.1.2 (memorized-secret-verifiers composition guidance)
|
||||
- [x] 1.5 Read `openspec/AGENTS.md` Sections 13, 14, 14a
|
||||
- [x] 1.6 Read CLAUDE.md (full) — type design, file size, doc rules
|
||||
- [x] 1.7 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.8 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [x] 1.9 Draft `specs/password-generator/spec.md` from `openspec/templates/spec-delta.md` with all requirements
|
||||
- [x] 1.10 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
- [x] 1.11 `bun openspec change validate enterprise-password-generator` clean
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [x] 2.1 `packages/vault/password-generator/` directory with `src/{random,charsets,password,passphrase,pin,wordlists,entropy,errors,schemas,observability,__benchmarks__}/`
|
||||
- [x] 2.2 `package.json` — `@suaveplan/password-generator@0.0.0`, `type: module`, `sideEffects: false`, deps: `@suaveplan/types`/`@suaveplan/error`/`@suaveplan/crypto-utils` workspace, `@opentelemetry/api`/`tslib`/`zod` via catalog
|
||||
- [x] 2.3 `tsconfig.lib.json` extends `@suaveplan/typescript-config/library.json`
|
||||
- [x] 2.4 `vite.config.ts` (build) + `vitest.config.ts` (`createTestConfig`, `provider: 'istanbul'`, 100/100/100/100)
|
||||
- [x] 2.5 `README.md` with description, install, quick-start, Modules table linking every co-located `.md`
|
||||
- [x] 2.6 Subpath exports in `package.json`: `.`, `./password`, `./passphrase`, `./pin`, `./entropy`, `./wordlists/eff-long`, `./wordlists/eff-short`, `./wordlists/registry`, `./random`, `./errors`, `./schemas`
|
||||
|
||||
## 3. Implementation — bias-free random
|
||||
|
||||
- [x] 3.1 `src/random/random-int.ts` — `randomInt(rng: Rng, max: number): number`, Lemire-style rejection sampling against `2^32 - (2^32 mod n)`; throws `PWGEN.RNG_FAILED` if `rng` returns a non-uint32 after a bounded loop
|
||||
- [x] 3.2 `src/random/fisher-yates.ts` — `shuffleInPlace<T>(array: T[], rng: Rng): T[]` using `randomInt`
|
||||
|
||||
## 4. Implementation — char-class password generator
|
||||
|
||||
- [x] 4.1 `src/charsets/charsets.ts` — `LOWERCASE`, `UPPERCASE`, `DIGITS`, `SYMBOLS`, `AMBIGUOUS`, `LOOKALIKES` `ReadonlySet<string>` constants; `buildEffectiveCharset(opts): string[]` applying inclusion + exclusion
|
||||
- [x] 4.2 `src/password/password.types.ts` — `GeneratePasswordOptions`, `PasswordMode`, `RequiredClasses`
|
||||
- [x] 4.3 `src/password/password.ts` — `generatePassword(opts, rng?)`: validate via Zod → reject infeasible (`length < requiredCount` OR empty effective charset) with `PWGEN.OPTIONS_INFEASIBLE` → place one char per required class → fill remainder from union → Fisher-Yates shuffle → return string
|
||||
|
||||
## 5. Implementation — pronounceable mode
|
||||
|
||||
- [x] 5.1 `src/password/pronounceable.ts` — English digram Markov table baked in, CVC syllable generator, `generatePronounceablePassword(opts, rng?)`. Documented as lower-entropy than random mode
|
||||
|
||||
## 6. Implementation — passphrase generator
|
||||
|
||||
- [x] 6.1 `src/passphrase/passphrase.types.ts` — `GeneratePassphraseOptions`, `CapitalizationMode = "none" | "firstChar" | "randomWord"`, `WordlistRef = "long" | "short" | string[]`
|
||||
- [x] 6.2 `src/passphrase/passphrase.ts` — `generatePassphrase(opts, rng?)`: resolve `WordlistRef` → registry → reject `< 256` entries with `PWGEN.WORDLIST_TOO_SHORT` → `randomInt`-pick `length` words → apply capitalisation mode → join with separator → optionally insert `addDigits`/`addSymbols` at randomised positions
|
||||
|
||||
## 7. Implementation — PIN mode
|
||||
|
||||
- [x] 7.1 `src/pin/pin.ts` — `generatePin(length, rng?)`: bias-free uniform `[0, 10)` over `length` digits; rejects `length < 1` with `PWGEN.OPTIONS_INFEASIBLE`
|
||||
|
||||
## 8. Implementation — wordlists
|
||||
|
||||
- [x] 8.1 `src/wordlists/eff-long.ts` — bundled EFF Long Wordlist (7776 entries, ASCII, deduplicated); exported as `EFF_LONG_WORDLIST: readonly string[]`
|
||||
- [x] 8.2 `src/wordlists/eff-short.ts` — bundled EFF Short Wordlist 1 (1296 entries, ASCII, deduplicated); exported as `EFF_SHORT_WORDLIST: readonly string[]`
|
||||
- [x] 8.3 `src/wordlists/registry.ts` — `registerWordlist(name, words[])` (rejects `< 256` with `PWGEN.WORDLIST_TOO_SHORT`), `getWordlist(name)` (throws `PWGEN.WORDLIST_NOT_REGISTERED` for unknown names), `listWordlists(): string[]`. Pre-populates registry with `eff-long` and `eff-short`
|
||||
|
||||
## 9. Implementation — entropy + strength
|
||||
|
||||
- [x] 9.1 `src/entropy/entropy.ts` — `estimateEntropy(password, opts?)` detects class membership and returns `{ bits: log2(charsetSize) * length, charsetSize }`; `estimatePassphraseEntropy(words, wordlistSize)` returns `log2(wordlistSize) * words.length`; `assertStrength(password, minBits, opts?)` throws `PWGEN.STRENGTH_INSUFFICIENT` if `bits < minBits`
|
||||
|
||||
## 10. Implementation — errors, schemas, observability
|
||||
|
||||
- [x] 10.1 `src/errors/codes.ts` — `defineCodes("PWGEN", [...])` with `INVALID_OPTIONS`, `OPTIONS_INFEASIBLE`, `WORDLIST_TOO_SHORT`, `WORDLIST_NOT_REGISTERED`, `STRENGTH_INSUFFICIENT`, `RNG_FAILED`
|
||||
- [x] 10.2 `src/schemas/schemas.ts` — Zod `GeneratePasswordOptionsSchema`, `GeneratePassphraseOptionsSchema`, `EntropyOptionsSchema`; mismatch → `PWGEN.INVALID_OPTIONS`
|
||||
- [x] 10.3 `src/observability/metrics.ts` — `pwgen.generate_duration_ms{mode}` histogram, `pwgen.entropy_bits{mode}` histogram, `pwgen.errors_total{code}` counter; wired from every production call site
|
||||
|
||||
## 11. Root barrel + subpath exports
|
||||
|
||||
- [x] 11.1 `src/index.ts` — explicit named re-exports (no `export *`)
|
||||
- [x] 11.2 Per-feature `index.ts` barrels — explicit named re-exports
|
||||
- [x] 11.3 `package.json` exports map covers every documented subpath
|
||||
|
||||
## 12. Documentation
|
||||
|
||||
- [x] 12.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference, Implementation Notes covering rejection-sampling rationale + entropy formula derivation)
|
||||
- [x] 12.2 README links every co-located `.md` under `## Modules`
|
||||
- [x] 12.3 Update CLAUDE.md package listing under `#### Vault orchestration` (or new sibling section) ONLY at archive time
|
||||
|
||||
## 13. Tests (runner-neutral imports — `@suaveplan/testing/runner`)
|
||||
|
||||
- [x] 13.1 `random/random-int.test.ts` — uniform output, rejection-loop hit, monotone bounds
|
||||
- [x] 13.2 `random/random-int.spec-conformance.test.ts` — 1M `randomInt(rng, 7776)` calls, χ² p > 0.01 vs uniform
|
||||
- [x] 13.3 `random/fisher-yates.test.ts` — permutation correctness, deterministic-seed reproducibility
|
||||
- [x] 13.4 `charsets/charsets.test.ts` — set membership, exclusion arithmetic
|
||||
- [x] 13.5 `password/password.test.ts` — happy path, exclusion sets, each required-class combination, infeasible rejection
|
||||
- [x] 13.6 `password/password.spec-conformance.test.ts` — fast-check property: required-class quotas always satisfied; deterministic-seed reproducibility
|
||||
- [x] 13.7 `password/pronounceable.test.ts` — CVC pattern; RNG injection
|
||||
- [x] 13.8 `passphrase/passphrase.test.ts` — word count, separator, every capitalisation mode, addDigits/addSymbols insertion
|
||||
- [x] 13.9 `passphrase/passphrase.spec-conformance.test.ts` — fast-check property: every word in output is a wordlist member; word count equals requested length
|
||||
- [x] 13.10 `wordlists/registry.test.ts` — register/get/list happy path, `< 256` rejection, unknown-name rejection
|
||||
- [x] 13.11 `wordlists/eff-long.test.ts` — length 7776, ASCII-only, no duplicates
|
||||
- [x] 13.12 `wordlists/eff-short.test.ts` — length 1296, ASCII-only, no duplicates
|
||||
- [x] 13.13 `pin/pin.test.ts` — every char in `[0-9]`, length matches
|
||||
- [x] 13.14 `pin/pin.spec-conformance.test.ts` — bias-free distribution check against `n = 10`
|
||||
- [x] 13.15 `entropy/entropy.test.ts` — class-detection table, log2 math, `assertStrength` throw and pass paths
|
||||
- [x] 13.16 `errors/codes.test.ts` — every registered code thrown by ≥1 production path
|
||||
- [x] 13.17 `schemas/schemas.test.ts` — every schema rejects malformed input with `PWGEN.INVALID_OPTIONS`
|
||||
- [x] 13.18 `observability/metrics.test.ts` — every histogram + counter asserted via in-memory `MeterProvider`
|
||||
- [x] 13.19 `index.test.ts` — every documented subpath resolves
|
||||
- [x] 13.20 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for the χ² stats test)
|
||||
|
||||
## 14. Benchmarks
|
||||
|
||||
- [x] 14.1 `src/__benchmarks__/password.bench.ts` — `gen_password_16`, `gen_passphrase_5`, `gen_passphrase_7`, `entropy_estimate_long_password`; baseline numbers in README
|
||||
|
||||
## 15. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [x] 15.1 `bun run check` — Biome 0/0/0
|
||||
- [x] 15.2 `bunx turbo lint typecheck test --filter=@suaveplan/password-generator` — green
|
||||
- [x] 15.3 `bunx turbo test:coverage --filter=@suaveplan/password-generator` — 100/100/100/100
|
||||
- [x] 15.4 `bun scripts/openspec-conformance.ts @suaveplan/password-generator` — exit 0, no `error` findings
|
||||
- [x] 15.5 `bunx turbo build --filter=@suaveplan/password-generator` — green
|
||||
|
||||
## 16. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 16.1 Generics threaded — `Rng` injectable; `shuffleInPlace<T>` parametric
|
||||
- [x] 16.2 Zod schemas wired at every declared boundary (`generatePassword`, `generatePassphrase`, `assertStrength`)
|
||||
- [x] 16.3 Every error code in `errors/codes.ts` thrown by ≥1 production path (no orphan registry entries — §2c of AGENTS)
|
||||
- [x] 16.4 Strict-mode behaviors tested both on and off (Zod strict-object boundary)
|
||||
- [x] 16.5 OTel instruments emitted from production paths, asserted via real in-memory `MeterProvider`
|
||||
- [x] 16.6 Lifecycle dedup — N/A (pure functions, no in-flight Promise to dedup)
|
||||
- [x] 16.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 17. Banned-pattern + structural scans (zero output expected)
|
||||
|
||||
- [x] 17.1 `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b\|console\.log" packages/vault/password-generator/src/ --include="*.ts"`
|
||||
- [x] 17.2 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/vault/password-generator/src/ --include="*.ts"`
|
||||
- [x] 17.3 `grep -rn "Math.random" packages/vault/password-generator/src/ --include="*.ts"` — zero (default `Rng` MUST come from `@suaveplan/crypto-utils.randomBytes`)
|
||||
- [x] 17.4 `grep -rn "biome-ignore" packages/vault/password-generator/src/ --include="*.ts"` — zero
|
||||
- [x] 17.5 Feature barrel `export *` scan — zero
|
||||
- [x] 17.6 File-size budget — every `.ts` under 300 lines, every `.test.ts` under 500
|
||||
- [x] 17.7 Sibling `.md` present for every implementation `.ts`
|
||||
|
||||
## 18. Archive
|
||||
|
||||
- [x] 18.1 Fold spec delta into `openspec/specs/password-generator/spec.md` (canonical, status ACTIVE)
|
||||
- [x] 18.2 Move `openspec/changes/enterprise-password-generator/` → `openspec/archive/<yyyy-mm-dd>-enterprise-password-generator/`
|
||||
- [ ] 18.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts` (orchestrator after parallel migrations land)
|
||||
- [ ] 18.4 Refresh `MISSING.yml` via `bun scripts/generate-missing.ts` (orchestrator)
|
||||
- [ ] 18.5 Update `STATUS.md` — move row from Pending → Completed (orchestrator)
|
||||
- [ ] 18.6 Update `SUMMARY.md` — add per-package paragraph (orchestrator)
|
||||
- [ ] 18.7 `bunx markdownlint-cli2 --fix` clean across touched files (orchestrator)
|
||||
Reference in New Issue
Block a user