Files
suaveplan/openspec/archive/2026-05-10-enterprise-password-strength/proposal.md
2026-05-27 23:29:53 +01:00

19 KiB
Raw Blame History

@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 Maps are Object.freezed 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).