Files
2026-05-27 23:29:53 +01:00

197 lines
19 KiB
Markdown
Raw Permalink 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-strength` — zxcvbn-style password strength estimator
**Package**: `@suaveplan/password-strength` (NEW)
**Status**: PROPOSED
**Type**: new-package (greenfield migration; canonical algorithm port from Wheeler 2016 + dropbox/zxcvbn reference impl)
**Date**: 2026-05-10
**Path**: `packages/vault/password-strength/`
**Tier**: universal (zero browser/Node/React deps)
## Why
`@suaveplan/vault` ships a password-manager UI surface that requires a real-world strength estimator on two paths: (1) an in-editor strength bar that updates as the user types a master password / item password and gates Save when `score < 3`, and (2) a post-save warning banner when an item is persisted with a weak password. Naive `log2(charsetSize) * length` entropy is wrong — `Password1!` clears the 12-char + symbol + digit + mixed-case bar but ranks in the top 1000 of every public breach corpus. zxcvbn's pattern-matching + dictionary cracker model is the published state-of-the-art for low-budget client-side strength estimation (Wheeler, USENIX Security 2016) and is the algorithm consumers expect from a strength meter.
We do not depend on the upstream `zxcvbn` npm package: it is dual-licensed GPLv2 + MIT (the GPL half is incompatible with the rest of the genesis MIT surface), it ships English-only dictionaries we want to swap and extend, its `n^2` DP has no input-length guard (a 10 KiB paste DoSes the renderer), and its API is callback-shaped where genesis is async/iterable-shaped. We re-implement the algorithm from the paper + reference impl in genesis style: Zod boundary schemas, OTel histograms + counters, structured `PWSTRENGTH.*` error codes, 100% line + branch coverage, fast-check property tests, mitata benchmarks, multilingual dictionary subpaths, runtime dictionary swap, observable streaming variant, and a hard 200-character DoS guard.
The reference algorithm has been independently reproduced in Python (zxcvbn-python), Go (nbutton23/zxcvbn-go), Rust (shssoichiro/zxcvbn-rs), and PHP (bjeavons/zxcvbn-php) — every reproduction agrees with Wheeler §4 closed-form formulas to ≤1 bit of `log10(guesses)` on the published test corpus. Our re-implementation MUST agree with the same corpus to the same tolerance; the conformance test fixture vendors the public test vectors from the reference impl's `test/test-matching.coffee` and `test/test-scoring.coffee` (MIT-licensed half).
## What changes
### New package: `@suaveplan/password-strength`
ADDED — package scaffold:
- `packages/vault/password-strength/package.json` — `@suaveplan/password-strength@0.0.0`, `type: module`, `sideEffects: false`, deps below
- `packages/vault/password-strength/tsconfig.lib.json` extends `@suaveplan/typescript-config/library`
- `packages/vault/password-strength/vite.config.ts` + `vitest.config.ts` (uses `createTestConfig()` from `@suaveplan/testing/config`)
- `packages/vault/password-strength/README.md` with Modules table linking every co-located `.md`
ADDED — pattern matchers (each in its own feature dir under `src/matchers/<name>/`):
- `dictionary` — frequency-ranked dictionary lookup with l33t-substitution (configurable substitution table, default `{a:[4,@], b:[8], e:[3], …}`) and reverse-string lookup; output `DictionaryMatch { pattern: "dictionary", i, j, token, matchedWord, rank, dictionaryName, reversed: boolean, l33t: boolean, sub?: Record<string,string>, subDisplay?: string }`
- `sequence` — alphabetical (`abcd`/`zyx`) and numeric (`1234`/`87`) sequences in either direction; output `SequenceMatch { pattern: "sequence", i, j, token, sequenceName: "lower"|"upper"|"digits", sequenceSpace: number, ascending: boolean }`
- `repeat` — repeated chunks (`aaaa`, `abcabc`, `babababa`); chunk length found via greedy regex, `baseGuesses` recursively computed by `mostGuessableMatchSequence` over the chunk; output `RepeatMatch { pattern: "repeat", i, j, token, baseToken, baseGuesses, repeatCount: number, baseMatches: Match[] }`
- `date` — splittable into year/month/day with `1900 ≤ year ≤ currentYear + 50`; supports separator (`/`, `-`, `.`, ` `) and no-separator forms (`mmddyyyy` / `yyyymmdd` / `mmyy` / `yyyy`); output `DateMatch { pattern: "date", i, j, token, year: number, month: number, day: number, separator: string }`
- `keyboard` — adjacency-graph walks (`qwerty`, `123456`, `qazwsx`); QWERTY default, DVORAK + AZERTY shipped under `./graphs/<layout>` subpaths and runtime-pluggable; output `KeyboardMatch { pattern: "spatial", i, j, token, graph: string, turns: number, shiftedCount: number }`
- `regex` — recent-year matcher (`19xx`/`20xx`); registry-pluggable for additional patterns; output `RegexMatch { pattern: "regex", i, j, token, regexName: "recent_year", regexMatch: RegExpMatchArray }`
ADDED — bundled dictionaries (frozen `Map<string, number>` from name → rank):
- `passwords` — top 10 000 from RockYou + HaveIBeenPwned breach corpora (rank-frequency)
- `english_wikipedia` — top 30 000 English Wikipedia tokens (rank-frequency)
- `male_names`, `female_names`, `surnames` — US Census name frequency tables
- Subpath `./dicts/en` re-exports the default English bundle; subpaths `./dicts/de`, `./dicts/fr`, `./dicts/es`, `./dicts/zh-Hans`, `./dicts/ru` ship Wikipedia top-30k for each locale (5 additional bundles)
- `loadDictionaries(dicts: PartialDictionaryMap): DictionarySet` runtime swap that returns a fresh `DictionarySet` (no mutation of the global default)
ADDED — scoring pipeline (`src/scoring/`):
- `mostGuessableMatchSequence(password, matches): MatchSequence` — DP over `n^2` candidate covers; minimises `Π guesses(match) × k!` (lowest-product cover, factorial penalty for `k` matches), tie-broken by fewest matches; output `{ password, guesses, guessesLog10, sequence: Match[] }`
- `estimateGuesses(match, password): number` — closed-form per pattern type: dictionary (`rank * uppercase_variations * l33t_variations * reversed_variations`), sequence (`base * length * (ascending?1:2)`), repeat (`baseGuesses * repeatCount`), date (`365 * |year - referenceYear| * (separator?4:1)`), keyboard (`Σ over (i,j,t,s) of (i_choose_j) * possible_starts * paths`), regex (year-distance heuristic) — every formula sourced from Wheeler §4 with line-anchored citations in the sibling `.md`
- `crackTimes(guesses, opts?: CrackTimeOptions): CrackTimes` — returns seconds for the four scenarios (`online_throttling_100_per_hour`, `online_no_throttling_10_per_second`, `offline_slow_hashing_1e4_per_second`, `offline_fast_hashing_1e10_per_second`), with optional `adjustments: { online_throttled?: number; online_no_throttling?: number; offline_slow?: number; offline_fast?: number }` multiplier map for site-specific tuning
- `score(guesses): 0 | 1 | 2 | 3 | 4` per Wheeler §5 buckets: `0 < 1e3`, `1 < 1e6`, `2 < 1e8`, `3 < 1e10`, `4 ≥ 1e10`
ADDED — public API (`src/assess/`):
- `assess(password: string, opts?: AssessOptions): StrengthResult` — full pipeline: input-length guard → run all matchers in parallel → `mostGuessableMatchSequence` → `crackTimes` → `score` → `feedback`; returns `{ password, guesses, guessesLog10, score, crackTimes, sequence, feedback, calcTimeMs }`
- `feedback(result, opts?: FeedbackOptions): { warning: string; suggestions: string[] }` — keyed strings via `@suaveplan/i18n` `MessageResolver` injected through `opts.resolver`; English fallback when no resolver supplied; warning keyed off the longest-cover pattern in `sequence`
- `assessReactive<T>(input$: AsyncIterable<string>, opts?: AssessReactiveOptions): AsyncIterable<StrengthResult>` — debounce (default 150ms) + cancel-on-newer-input wrapper; backed by `AbortController` per pending compute; ABORT semantics yield no result for the cancelled input
- `loadDictionaries(dicts: PartialDictionaryMap): DictionarySet` — fresh dictionary set; `assess(pwd, { dictionaries: customSet })` swaps for that call only
ADDED — error registry (`src/errors/error-codes.ts`):
- `PWSTRENGTH.INPUT_TOO_LONG` — input >200 chars (DoS guard); thrown by `assess` and `assessReactive`
- `PWSTRENGTH.INVALID_OPTIONS` — Zod parse failure on `AssessOptions` / `FeedbackOptions` / `AssessReactiveOptions`
- `PWSTRENGTH.DICTIONARY_INVALID` — dictionary entry has non-positive rank or non-string token; thrown by `loadDictionaries`
- `PWSTRENGTH.GRAPH_INVALID` — keyboard graph has self-adjacency or non-symmetric edges; thrown when `keyboardMatch` is invoked with a malformed graph
- `PWSTRENGTH.RESOLVER_FAILED` — `feedback` resolver threw; original `cause` preserved
- `PWSTRENGTH.ABORTED` — `assessReactive` cancelled mid-compute (only surfaced on the abort path; not thrown to consumers, surfaced via the iterable's completion)
ADDED — Zod boundary schemas (`src/schemas/`):
- `AssessOptionsSchema` — `{ dictionaries?, userInputs?: string[], graphs?, l33tTable? }`
- `FeedbackOptionsSchema` — `{ resolver?, locale?: string }`
- `AssessReactiveOptionsSchema` — `{ debounceMs?: number ≥ 0 ≤ 5000, signal?: AbortSignal }`
- `CrackTimeOptionsSchema` — `{ adjustments?: { online_throttled?: number > 0, online_no_throttling?: number > 0, offline_slow?: number > 0, offline_fast?: number > 0 } }`
- `MatchSchema` — discriminated union over all 6 pattern types
- `StrengthResultSchema` — full result shape (used in conformance tests)
ADDED — observability (`src/observability/metrics.ts`):
- `pwstrength.assess_duration_ms{score}` histogram — wall-time of `assess` from input to return, bucketed by output `score`
- `pwstrength.score_distribution_total{score}` counter — count of returned scores (0-4)
- `pwstrength.errors_total{code}` counter — count of thrown `PWSTRENGTH.*` codes
- `pwstrength.matchers_run_total{matcher}` counter — count of pattern matches found, by matcher name (5 + regex variants)
ADDED — benchmarks (`src/__benchmarks__/`):
- `assess_short_password.bench.ts` — 8-char password
- `assess_long_password.bench.ts` — 64-char password
- `assess_dictionary_heavy.bench.ts` — pure-dictionary input
- `dp_n100.bench.ts`, `dp_n200.bench.ts` — DP scaling at the 100 and 200-char input bounds
### Subpath exports
- `.` — root barrel
- `./assess` — `assess`, `assessReactive`, types
- `./feedback` — `feedback`, types
- `./scoring` — `mostGuessableMatchSequence`, `estimateGuesses`, `crackTimes`, `score`
- `./matchers/dictionary`, `./matchers/sequence`, `./matchers/repeat`, `./matchers/date`, `./matchers/keyboard`, `./matchers/regex`
- `./dicts/en`, `./dicts/de`, `./dicts/fr`, `./dicts/es`, `./dicts/zh-Hans`, `./dicts/ru` — locale dictionaries
- `./graphs/qwerty`, `./graphs/dvorak`, `./graphs/azerty` — keyboard graphs
- `./errors` — `PWSTRENGTH_CODES`, `PasswordStrengthError`
- `./schemas` — every Zod schema named above
### Dependencies
- `@suaveplan/error workspace:^` — `defineCodes` + `SuaveplanError` base
- `@suaveplan/types workspace:^` — `MessageResolver` (i18n bridge contract; declared in `@suaveplan/types`, no runtime dep on `@suaveplan/i18n`)
- `@suaveplan/datetime workspace:^` — `formatRelativeTime` for humanized crack-time strings inside `feedback`
- `@opentelemetry/api catalog:observability` — meter / histogram / counter
- `tslib catalog:build`
- `zod catalog:forms` — boundary schemas
No runtime dep on `@suaveplan/i18n` (resolver is structural, threaded through `FeedbackOptions.resolver`); no runtime dep on `@suaveplan/vault` (consumer); no Node / browser / React deps.
## Out of scope
- Hibp k-anonymity online check (network round-trip changes the threat model; `@suaveplan/breach-check` is a separate enterprise package).
- Differential privacy on emitted metrics (the score/duration histogram has no PII; password text is never emitted).
- TypeScript-side mutation of bundled dictionaries (consumers extend via `loadDictionaries`; the bundled `Map`s are `Object.freeze`d and the wrapper rejects mutation attempts).
- Async pattern matchers (every matcher is synchronous; `assessReactive` is async only at the iteration boundary, not at the matcher boundary).
- Argon2-grade GPU-cluster cracking scenarios beyond the four bundled — site operators tune via `CrackTimeOptions.adjustments` rather than carry a fifth fixed scenario.
- `intl-messageformat` integration for `feedback` — the resolver is the integration point; `feedback` returns keys + interpolation values and the resolver renders them.
- Replacing the hand-rolled DP with a SAT solver — the `n^2` DP under a 200-char cap is already <5ms p99 on commodity hardware (verified by the `dp_n200.bench.ts` baseline).
## Source mapping
Sources:
- **W16** — Wheeler, _zxcvbn: Low-Budget Password Strength Estimation_, USENIX Security 2016 (`https://www.usenix.org/system/files/conference/usenixsecurity16/sec16_paper_wheeler.pdf`)
- **RZX** — Reference impl `dropbox/zxcvbn` at `master` (MIT half of the dual licence)
- **EPS** — `../suaveplan/enterprise/password-strength.md`
| Requirement | Source | Notes |
|---|---|---|
| Dictionary matcher with l33t + reverse | W16 §3.1 "Dictionary matching"; RZX `src/matching.coffee::dictionary_match`, `relevant_l33t_subtable`, `enumerate_l33t_subs` | Closed-form l33t / uppercase variation factor in Wheeler §4.1 |
| Sequence matcher | W16 §3.2 "Sequences"; RZX `src/matching.coffee::sequence_match` | Alphabetical + numeric, both directions |
| Repeat matcher | W16 §3.3 "Repeats"; RZX `src/matching.coffee::repeat_match` | Greedy chunk regex; `baseGuesses` recurses through `mostGuessableMatchSequence` |
| Date matcher | W16 §3.4 "Dates"; RZX `src/matching.coffee::date_match`, `date_without_sep_match`, `date_with_sep_match` | `1900 ≤ year ≤ now + 50` |
| Keyboard adjacency matcher | W16 §3.5 "Spatial / keyboard adjacency"; RZX `src/matching.coffee::spatial_match`, `data/adjacency_graphs.coffee` | QWERTY default; DVORAK + AZERTY under subpaths |
| Regex matcher | W16 §3.6 "Regex"; RZX `src/matching.coffee::regex_match` | Recent-year only by default |
| `mostGuessableMatchSequence` DP | W16 §4 "Search"; RZX `src/scoring.coffee::most_guessable_match_sequence` | `n^2` DP, factorial penalty, fewest-matches tie-break |
| `estimateGuesses` per pattern | W16 §4.1–§4.6; RZX `src/scoring.coffee::estimate_guesses` family | Closed-form formulas; per-pattern `.md` cites paper line range |
| `crackTimes` four scenarios | W16 §5 Table 1; RZX `src/time_estimates.coffee` | `100/h`, `10/s`, `1e4/s`, `1e10/s` |
| Score buckets `0–4` | W16 §5 "Score" definition; RZX `src/time_estimates.coffee::guesses_to_score` | `<1e3, <1e6, <1e8, <1e10, ≥1e10` |
| `feedback` keyed strings | EPS §3 "Feedback"; RZX `src/feedback.coffee` | Resolver-driven; English fallback |
| User-input dictionary | EPS §4 "User-input dictionary"; RZX `zxcvbn(password, user_inputs)` first arg | Per-call; not global |
| Bundled dictionaries (`passwords`, `english_wikipedia`, `male_names`, `female_names`, `surnames`) | RZX `data/frequency_lists.coffee` (vendored) | MIT half of upstream licence |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `INPUT_TOO_LONG` 200-char DoS guard | RZX has none; `n^2` DP is a real DoS hazard on user-controlled input. Genesis hardening (EPS §3 cross-cutting). |
| `assessReactive` observable wrapper | EPS §4 "Streaming feedback"; not in RZX. |
| `crackTimes.adjustments` map | EPS §4 "Online-attack adjustment"; not in RZX. |
| Multilingual dictionary subpaths (`de`/`fr`/`es`/`zh-Hans`/`ru`) | EPS §4 "Multilingual dictionaries"; RZX is English-only. |
| Zod boundary schemas | Genesis convention (CLAUDE.md "Quality"); RZX has no runtime validation. |
| OTel histograms + counters | Genesis convention; RZX has no observability. |
| `PWSTRENGTH.*` error code registry | Genesis convention via `defineCodes`; RZX returns `null` / silent fallbacks. |
| Keyboard graph subpaths (`./graphs/<layout>`) | EPS §3 "Keyboard adjacency"; RZX bundles all graphs eagerly. Subpaths give callers tree-shake control. |
| `loadDictionaries` runtime swap returning fresh `DictionarySet` (no global mutation) | Genesis convention (no global state); RZX mutates a module-level `RANKED_DICTIONARIES`. |
## Verification approach
Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles. Test layout:
- `src/matchers/dictionary/dictionary.test.ts` — l33t enumeration, reverse, ranked output, every bundled dictionary contributes
- `src/matchers/sequence/sequence.test.ts` — ascending + descending, lower / upper / digits, length-2 minimum
- `src/matchers/repeat/repeat.test.ts` — single-char, multi-char chunks, recursive `baseGuesses`
- `src/matchers/date/date.test.ts` — separator + no-separator forms, year bounds, ambiguous parses preferred toward year-closest-to-reference
- `src/matchers/keyboard/keyboard.test.ts` — QWERTY walks, shifted-count, turns, swap-graph injection (DVORAK + AZERTY)
- `src/matchers/regex/regex.test.ts` — recent-year, custom registered regex
- `src/scoring/most-guessable-match-sequence.test.ts` — empty input, single-match cover, multi-match cover, factorial penalty, fewest-matches tie-break, recursive repeat-baseGuesses
- `src/scoring/estimate-guesses.test.ts` — closed-form for every pattern type; matches Wheeler corpus to ≤1 bit log10
- `src/scoring/crack-times.test.ts` — four scenarios at boundary `guesses` values; `adjustments` multiplier; humanized strings via `formatRelativeTime`
- `src/scoring/score.test.ts` — bucket boundaries (`1e3 - 1`, `1e3`, `1e6 - 1`, `1e6`, …, `1e10`)
- `src/assess/assess.test.ts` — full pipeline; `userInputs`; custom dictionaries; 200-char rejection; 199-char acceptance
- `src/assess/assess-reactive.test.ts` — debounce, cancel-on-newer, abort signal
- `src/feedback/feedback.test.ts` — resolver dispatch; English fallback; warning keyed off longest-cover pattern; `RESOLVER_FAILED` on resolver throw
- `src/errors/error-codes.test.ts` — every code in the registry is thrown by ≥1 production path
- `src/observability/metrics.test.ts` — every histogram / counter recorded against an in-memory `MeterProvider`
- `src/schemas/schemas.test.ts` — every schema rejects malformed input with `INVALID_OPTIONS`
- `src/conformance.test.ts` — vendored Wheeler test corpus (~120 vectors) agrees to ≤1 bit `log10(guesses)` and exact `score` bucket
- `src/properties.test.ts` — fast-check property suite: matcher commutativity (matcher order does not affect `mostGuessableMatchSequence`); score-bucket monotonicity (`g1 ≤ g2 → score(g1) ≤ score(g2)`); `crackTimes` monotonicity; identity (`assess(p)` calling twice returns the same result); `INPUT_TOO_LONG` boundary
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/password-strength` to enumerate. At authoring time:
- `@suaveplan/vault` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: vault consumer migration is a follow-up change `vault-strength-bar` once this package ships; this proposal is additive only).
No dependents at landing time (verified by `bunx turbo ls --filter=...@suaveplan/password-strength` — package does not yet exist on genesis). The `@suaveplan/vault` consumer migration is scoped to a follow-up change so the strength-meter wiring lands with vault's UI work, not here.
`MANIFEST.md`, `STATUS.md`, and `CLAUDE.md` package listing updated as part of the implementation change (NOT this proposal — this folder is openspec contract only).