# `@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//`): - `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, 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/` 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` 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(input$: AsyncIterable, opts?: AssessReactiveOptions): AsyncIterable` — 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/`) | 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).