19 KiB
@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 belowpackages/vault/password-strength/tsconfig.lib.jsonextends@suaveplan/typescript-config/librarypackages/vault/password-strength/vite.config.ts+vitest.config.ts(usescreateTestConfig()from@suaveplan/testing/config)packages/vault/password-strength/README.mdwith 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; outputDictionaryMatch { 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; outputSequenceMatch { 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,baseGuessesrecursively computed bymostGuessableMatchSequenceover the chunk; outputRepeatMatch { pattern: "repeat", i, j, token, baseToken, baseGuesses, repeatCount: number, baseMatches: Match[] }date— splittable into year/month/day with1900 ≤ year ≤ currentYear + 50; supports separator (/,-,.,) and no-separator forms (mmddyyyy/yyyymmdd/mmyy/yyyy); outputDateMatch { 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; outputKeyboardMatch { pattern: "spatial", i, j, token, graph: string, turns: number, shiftedCount: number }regex— recent-year matcher (19xx/20xx); registry-pluggable for additional patterns; outputRegexMatch { 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/enre-exports the default English bundle; subpaths./dicts/de,./dicts/fr,./dicts/es,./dicts/zh-Hans,./dicts/ruship Wikipedia top-30k for each locale (5 additional bundles) loadDictionaries(dicts: PartialDictionaryMap): DictionarySetruntime swap that returns a freshDictionarySet(no mutation of the global default)
ADDED — scoring pipeline (src/scoring/):
mostGuessableMatchSequence(password, matches): MatchSequence— DP overn^2candidate covers; minimisesΠ guesses(match) × k!(lowest-product cover, factorial penalty forkmatches), 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.mdcrackTimes(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 optionaladjustments: { online_throttled?: number; online_no_throttling?: number; offline_slow?: number; offline_fast?: number }multiplier map for site-specific tuningscore(guesses): 0 | 1 | 2 | 3 | 4per 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/i18nMessageResolverinjected throughopts.resolver; English fallback when no resolver supplied; warning keyed off the longest-cover pattern insequenceassessReactive<T>(input$: AsyncIterable<string>, opts?: AssessReactiveOptions): AsyncIterable<StrengthResult>— debounce (default 150ms) + cancel-on-newer-input wrapper; backed byAbortControllerper pending compute; ABORT semantics yield no result for the cancelled inputloadDictionaries(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 byassessandassessReactivePWSTRENGTH.INVALID_OPTIONS— Zod parse failure onAssessOptions/FeedbackOptions/AssessReactiveOptionsPWSTRENGTH.DICTIONARY_INVALID— dictionary entry has non-positive rank or non-string token; thrown byloadDictionariesPWSTRENGTH.GRAPH_INVALID— keyboard graph has self-adjacency or non-symmetric edges; thrown whenkeyboardMatchis invoked with a malformed graphPWSTRENGTH.RESOLVER_FAILED—feedbackresolver threw; originalcausepreservedPWSTRENGTH.ABORTED—assessReactivecancelled 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 typesStrengthResultSchema— full result shape (used in conformance tests)
ADDED — observability (src/observability/metrics.ts):
pwstrength.assess_duration_ms{score}histogram — wall-time ofassessfrom input to return, bucketed by outputscorepwstrength.score_distribution_total{score}counter — count of returned scores (0-4)pwstrength.errors_total{code}counter — count of thrownPWSTRENGTH.*codespwstrength.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 passwordassess_long_password.bench.ts— 64-char passwordassess_dictionary_heavy.bench.ts— pure-dictionary inputdp_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+SuaveplanErrorbase@suaveplan/types workspace:^—MessageResolver(i18n bridge contract; declared in@suaveplan/types, no runtime dep on@suaveplan/i18n)@suaveplan/datetime workspace:^—formatRelativeTimefor humanized crack-time strings insidefeedback@opentelemetry/api catalog:observability— meter / histogram / countertslib catalog:buildzod 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-checkis 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 bundledMaps areObject.freezed and the wrapper rejects mutation attempts). - Async pattern matchers (every matcher is synchronous;
assessReactiveis 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.adjustmentsrather than carry a fifth fixed scenario. intl-messageformatintegration forfeedback— the resolver is the integration point;feedbackreturns keys + interpolation values and the resolver renders them.- Replacing the hand-rolled DP with a SAT solver — the
n^2DP under a 200-char cap is already <5ms p99 on commodity hardware (verified by thedp_n200.bench.tsbaseline).
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/zxcvbnatmaster(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 contributessrc/matchers/sequence/sequence.test.ts— ascending + descending, lower / upper / digits, length-2 minimumsrc/matchers/repeat/repeat.test.ts— single-char, multi-char chunks, recursivebaseGuessessrc/matchers/date/date.test.ts— separator + no-separator forms, year bounds, ambiguous parses preferred toward year-closest-to-referencesrc/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 regexsrc/scoring/most-guessable-match-sequence.test.ts— empty input, single-match cover, multi-match cover, factorial penalty, fewest-matches tie-break, recursive repeat-baseGuessessrc/scoring/estimate-guesses.test.ts— closed-form for every pattern type; matches Wheeler corpus to ≤1 bit log10src/scoring/crack-times.test.ts— four scenarios at boundaryguessesvalues;adjustmentsmultiplier; humanized strings viaformatRelativeTimesrc/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 acceptancesrc/assess/assess-reactive.test.ts— debounce, cancel-on-newer, abort signalsrc/feedback/feedback.test.ts— resolver dispatch; English fallback; warning keyed off longest-cover pattern;RESOLVER_FAILEDon resolver throwsrc/errors/error-codes.test.ts— every code in the registry is thrown by ≥1 production pathsrc/observability/metrics.test.ts— every histogram / counter recorded against an in-memoryMeterProvidersrc/schemas/schemas.test.ts— every schema rejects malformed input withINVALID_OPTIONSsrc/conformance.test.ts— vendored Wheeler test corpus (~120 vectors) agrees to ≤1 bitlog10(guesses)and exactscorebucketsrc/properties.test.ts— fast-check property suite: matcher commutativity (matcher order does not affectmostGuessableMatchSequence); score-bucket monotonicity (g1 ≤ g2 → score(g1) ≤ score(g2));crackTimesmonotonicity; identity (assess(p)calling twice returns the same result);INPUT_TOO_LONGboundary
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 changevault-strength-baronce 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).