Initial Commit
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# `@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).
|
||||
Reference in New Issue
Block a user