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).
|
||||
@@ -0,0 +1,562 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: password-strength (enterprise-password-strength)
|
||||
|
||||
**Package**: `@suaveplan/password-strength`
|
||||
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Dictionary pattern matcher with l33t and reverse-string lookup
|
||||
|
||||
The package SHALL export `dictionaryMatch(password, dictionaries)` returning every substring of `password` that exists in any supplied dictionary, with one match record per (substring, dictionary, l33t-substitution-set, reversed-flag) tuple. The matcher SHALL enumerate every l33t substitution combination from the configured `l33tTable` (default per Wheeler §3.1) and SHALL also test the reversed string. Every match SHALL carry `pattern: "dictionary"`, the matched substring, the matched dictionary entry, the rank, and the dictionary name. The matcher SHALL NOT mutate the supplied `dictionaries` argument.
|
||||
|
||||
#### Scenario: matches a plain dictionary token
|
||||
|
||||
- **WHEN** `dictionaryMatch("password", { passwords: new Map([["password", 1]]) })` runs
|
||||
- **THEN** the result contains exactly one match with `i=0`, `j=7`, `token="password"`, `matchedWord="password"`, `rank=1`, `l33t=false`, `reversed=false`
|
||||
|
||||
#### Scenario: matches a l33t-substituted token
|
||||
|
||||
- **WHEN** `dictionaryMatch("p4ssw0rd", { passwords: new Map([["password", 1]]) })` runs with the default l33t table
|
||||
- **THEN** the result contains a match with `l33t=true`, `sub: { "4": "a", "0": "o" }`, `matchedWord="password"`
|
||||
|
||||
#### Scenario: matches a reversed token
|
||||
|
||||
- **WHEN** `dictionaryMatch("drowssap", { passwords: new Map([["password", 1]]) })` runs
|
||||
- **THEN** the result contains a match with `reversed=true`, `matchedWord="password"`, `i=0`, `j=7`
|
||||
|
||||
#### Scenario: emits one match per (l33t, reversed) tuple
|
||||
|
||||
- **WHEN** the same substring matches under both l33t-true and reversed-true
|
||||
- **THEN** two distinct match records are emitted (the cover algorithm picks the lower-guesses one)
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a plain dictionary token"` AND `packages/vault/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a l33t-substituted token"` AND `packages/vault/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a reversed token"` AND `packages/vault/password-strength/src/matchers/dictionary/dictionary.test.ts::"emits one match per l33t-reversed tuple"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Sequence pattern matcher
|
||||
|
||||
The package SHALL export `sequenceMatch(password)` returning every substring of length ≥ 2 that is an alphabetical (`abcd`, `zyx`) or numeric (`1234`, `87`) sequence in either direction. Every match SHALL carry `pattern: "sequence"`, `sequenceName: "lower" | "upper" | "digits"`, `sequenceSpace: 26 | 26 | 10`, and `ascending: boolean`.
|
||||
|
||||
#### Scenario: matches an ascending alphabetical run
|
||||
|
||||
- **WHEN** `sequenceMatch("abcd")` runs
|
||||
- **THEN** the result contains one match with `i=0`, `j=3`, `ascending=true`, `sequenceName="lower"`
|
||||
|
||||
#### Scenario: matches a descending numeric run
|
||||
|
||||
- **WHEN** `sequenceMatch("9876")` runs
|
||||
- **THEN** the result contains one match with `ascending=false`, `sequenceName="digits"`, `sequenceSpace=10`
|
||||
|
||||
#### Scenario: rejects length-1 substrings
|
||||
|
||||
- **WHEN** `sequenceMatch("a")` runs
|
||||
- **THEN** the result is an empty array
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/matchers/sequence/sequence.test.ts::"matches ascending alphabetical run"` AND `packages/vault/password-strength/src/matchers/sequence/sequence.test.ts::"matches descending numeric run"` AND `packages/vault/password-strength/src/matchers/sequence/sequence.test.ts::"rejects length-1 substrings"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Repeat pattern matcher
|
||||
|
||||
The package SHALL export `repeatMatch(password, dictionaries)` returning every substring composed of a repeating chunk of length ≥ 1 (e.g. `aaaa`, `abcabc`, `babababa`). Every match SHALL carry `pattern: "repeat"`, `baseToken` (the repeating chunk), `repeatCount`, and `baseGuesses` computed by recursively running `mostGuessableMatchSequence` on the `baseToken` with the supplied dictionaries.
|
||||
|
||||
#### Scenario: matches a single-character repeat
|
||||
|
||||
- **WHEN** `repeatMatch("aaaa", emptyDictionaries)` runs
|
||||
- **THEN** the result contains a match with `baseToken="a"`, `repeatCount=4`
|
||||
|
||||
#### Scenario: matches a multi-character repeat
|
||||
|
||||
- **WHEN** `repeatMatch("abcabc", emptyDictionaries)` runs
|
||||
- **THEN** the result contains a match with `baseToken="abc"`, `repeatCount=2`
|
||||
|
||||
#### Scenario: recursively scores baseToken via mostGuessableMatchSequence
|
||||
|
||||
- **WHEN** `repeatMatch("passwordpassword", { passwords: new Map([["password", 1]]) })` runs
|
||||
- **THEN** the resulting match's `baseGuesses` equals `mostGuessableMatchSequence("password", […]).guesses`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/matchers/repeat/repeat.test.ts::"matches single-character repeat"` AND `packages/vault/password-strength/src/matchers/repeat/repeat.test.ts::"matches multi-character repeat"` AND `packages/vault/password-strength/src/matchers/repeat/repeat.test.ts::"recursively scores baseToken"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Date pattern matcher
|
||||
|
||||
The package SHALL export `dateMatch(password, opts?: { referenceYear?: number })` returning every substring that parses as a date with `1900 ≤ year ≤ referenceYear + 50` (default `referenceYear` is current year). Both separator (`/`, `-`, `.`, ` `) and no-separator (`mmddyyyy`, `yyyymmdd`, `mmyy`, `yyyy`) forms SHALL be recognised. Every match SHALL carry `pattern: "date"`, `year`, `month`, `day`, `separator`. When a substring admits multiple date parses, the matcher SHALL emit the parse whose `year` is closest to `referenceYear`.
|
||||
|
||||
#### Scenario: matches a separator date
|
||||
|
||||
- **WHEN** `dateMatch("11/22/1985")` runs with `referenceYear=2026`
|
||||
- **THEN** the result contains a match with `year=1985`, `month=11`, `day=22`, `separator="/"`
|
||||
|
||||
#### Scenario: matches a no-separator date
|
||||
|
||||
- **WHEN** `dateMatch("11221985")` runs with `referenceYear=2026`
|
||||
- **THEN** the result contains a match with `year=1985`, `month=11`, `day=22`, `separator=""`
|
||||
|
||||
#### Scenario: rejects out-of-range year
|
||||
|
||||
- **WHEN** `dateMatch("11/22/1850")` runs with `referenceYear=2026`
|
||||
- **THEN** the result does NOT contain a date match
|
||||
|
||||
#### Scenario: prefers year closest to reference
|
||||
|
||||
- **WHEN** `dateMatch("010199")` runs with `referenceYear=2026` (parseable as 1999 or 2099)
|
||||
- **THEN** the resulting match has `year=2099` (closest to `referenceYear + 50` window)
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/matchers/date/date.test.ts::"matches separator date"` AND `packages/vault/password-strength/src/matchers/date/date.test.ts::"matches no-separator date"` AND `packages/vault/password-strength/src/matchers/date/date.test.ts::"rejects out-of-range year"` AND `packages/vault/password-strength/src/matchers/date/date.test.ts::"prefers year closest to reference"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Keyboard adjacency pattern matcher
|
||||
|
||||
The package SHALL export `keyboardMatch(password, opts?: { graphs?: KeyboardGraphMap })` returning every substring that traces a walk on a keyboard adjacency graph. QWERTY graph SHALL be the default. DVORAK and AZERTY graphs SHALL be exported under subpaths `./graphs/dvorak` and `./graphs/azerty` respectively and SHALL be runtime-pluggable via the `graphs` option. Every match SHALL carry `pattern: "spatial"`, `graph` (graph name), `turns` (count of direction changes), and `shiftedCount` (count of shifted-key presses on the walk). The matcher SHALL throw `PWSTRENGTH.GRAPH_INVALID` when invoked with a graph containing self-adjacency or non-symmetric edges.
|
||||
|
||||
#### Scenario: matches a QWERTY straight-line walk
|
||||
|
||||
- **WHEN** `keyboardMatch("qwerty")` runs with the default QWERTY graph
|
||||
- **THEN** the result contains a match with `graph="qwerty"`, `turns=0`, `shiftedCount=0`, `i=0`, `j=5`
|
||||
|
||||
#### Scenario: counts turns on direction change
|
||||
|
||||
- **WHEN** `keyboardMatch("qazwsx")` runs
|
||||
- **THEN** the resulting match's `turns` is ≥ 1
|
||||
|
||||
#### Scenario: counts shifted keys
|
||||
|
||||
- **WHEN** `keyboardMatch("QWERTY")` runs
|
||||
- **THEN** the resulting match's `shiftedCount` equals `6`
|
||||
|
||||
#### Scenario: rejects malformed graph
|
||||
|
||||
- **WHEN** `keyboardMatch("qwerty", { graphs: { broken: { q: ["q"] } } })` runs
|
||||
- **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.GRAPH_INVALID"`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/matchers/keyboard/keyboard.test.ts::"matches QWERTY straight-line walk"` AND `packages/vault/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts turns on direction change"` AND `packages/vault/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts shifted keys"` AND `packages/vault/password-strength/src/matchers/keyboard/keyboard.test.ts::"rejects malformed graph"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Regex pattern matcher (recent-year + registry-pluggable)
|
||||
|
||||
The package SHALL export `regexMatch(password, opts?: { regexes?: Record<string, RegExp> })` returning every substring matching a registered regex. The default registry SHALL include `recent_year` matching `19xx` / `20xx`. Callers MAY register additional regexes via `opts.regexes`. Every match SHALL carry `pattern: "regex"`, `regexName`, and `regexMatch: RegExpMatchArray`.
|
||||
|
||||
#### Scenario: matches a recent year
|
||||
|
||||
- **WHEN** `regexMatch("login1985")` runs
|
||||
- **THEN** the result contains a match with `regexName="recent_year"`, `token="1985"`
|
||||
|
||||
#### Scenario: matches a custom registered regex
|
||||
|
||||
- **WHEN** `regexMatch("ABCDEF", { regexes: { hex: /[0-9A-F]+/g } })` runs
|
||||
- **THEN** the result contains a match with `regexName="hex"`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/matchers/regex/regex.test.ts::"matches recent year"` AND `packages/vault/password-strength/src/matchers/regex/regex.test.ts::"matches custom registered regex"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `mostGuessableMatchSequence` dynamic-programming cover
|
||||
|
||||
The package SHALL export `mostGuessableMatchSequence(password, matches): MatchSequence` returning `{ password, guesses, guessesLog10, sequence }` where `sequence` is the lowest-`Π guesses(match) × k!` cover of `password` (factorial penalty for `k` matches per Wheeler §4). When two covers tie on product-times-factorial, the cover with fewer matches SHALL win. The DP SHALL be `O(n^2)` over `n = password.length`. Empty input SHALL return `{ guesses: 1, guessesLog10: 0, sequence: [] }`.
|
||||
|
||||
#### Scenario: empty input
|
||||
|
||||
- **WHEN** `mostGuessableMatchSequence("", [])` runs
|
||||
- **THEN** the result equals `{ password: "", guesses: 1, guessesLog10: 0, sequence: [] }`
|
||||
|
||||
#### Scenario: single-match cover
|
||||
|
||||
- **WHEN** the input has exactly one match covering the entire password
|
||||
- **THEN** the resulting `sequence` contains exactly that match and `guesses` equals `match.guesses × 1!`
|
||||
|
||||
#### Scenario: prefers fewer-match cover on tie
|
||||
|
||||
- **WHEN** two covers have identical `Π guesses × k!`
|
||||
- **THEN** the cover with fewer matches is returned
|
||||
|
||||
#### Scenario: factorial penalty
|
||||
|
||||
- **WHEN** a 2-match cover has guess product `1000`
|
||||
- **THEN** the resulting `guesses` equals `1000 × 2! = 2000`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"empty input returns 1 guess"` AND `packages/vault/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"single-match cover"` AND `packages/vault/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"prefers fewer-match cover on tie"` AND `packages/vault/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"applies factorial penalty"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `estimateGuesses` per pattern type
|
||||
|
||||
The package SHALL export `estimateGuesses(match, password): number` implementing the Wheeler §4 closed-form formulas: dictionary (`rank × uppercaseVariations × l33tVariations × (reversed ? 2 : 1)`), sequence (`base × length × (ascending ? 1 : 2)` where `base ∈ {4, 26, 26, 10}` for one-char / lower / upper / digits), repeat (`baseGuesses × repeatCount`), date (`365 × |year - referenceYear| × (separator ? 4 : 1)`), keyboard (`Σ_{i,j,t,s} (i_choose_j) × possible_starts × paths` per Wheeler §4.5), regex (year-distance heuristic for `recent_year`).
|
||||
|
||||
#### Scenario: dictionary closed form
|
||||
|
||||
- **GIVEN** a match `{ pattern: "dictionary", rank: 100, l33t: false, reversed: false, token: "password" }`
|
||||
- **WHEN** `estimateGuesses(match, "password")` runs
|
||||
- **THEN** the result equals `100` (no uppercase, no l33t, no reverse multipliers)
|
||||
|
||||
#### Scenario: dictionary with reverse and l33t multipliers
|
||||
|
||||
- **GIVEN** a match `{ pattern: "dictionary", rank: 100, l33t: true, sub: { "4": "a" }, reversed: true, token: "p4ssword" }`
|
||||
- **WHEN** `estimateGuesses(match, "p4ssword")` runs
|
||||
- **THEN** the result is `≥ 100 × 2 × l33tVariations(sub)` (reverse and l33t multipliers applied)
|
||||
|
||||
#### Scenario: agrees with Wheeler corpus to ≤1 bit log10
|
||||
|
||||
- **WHEN** every vector in the vendored Wheeler test corpus is fed through `estimateGuesses`
|
||||
- **THEN** every result agrees to `|Δ log10(guesses)| ≤ 1`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary closed form"` AND `packages/vault/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary reverse and l33t multipliers"` AND `packages/vault/password-strength/src/conformance.test.ts::"Wheeler corpus agrees to <=1 bit log10"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `crackTimes` four-scenario calculator
|
||||
|
||||
The package SHALL export `crackTimes(guesses, opts?: CrackTimeOptions): CrackTimes` returning a record `{ online_throttling_100_per_hour, online_no_throttling_10_per_second, offline_slow_hashing_1e4_per_second, offline_fast_hashing_1e10_per_second }` where each value is `guesses / rate × (opts.adjustments?.<scenario> ?? 1)`. All four scenarios SHALL always be present in the output. Adjustments map values SHALL be positive numbers; Zod boundary SHALL reject zero or negative.
|
||||
|
||||
#### Scenario: four scenarios at boundary guesses
|
||||
|
||||
- **WHEN** `crackTimes(1e10)` runs
|
||||
- **THEN** the result has `online_throttling_100_per_hour ≈ 1e10 / (100/3600)` AND `offline_fast_hashing_1e10_per_second = 1`
|
||||
|
||||
#### Scenario: applies adjustments multiplier
|
||||
|
||||
- **WHEN** `crackTimes(1e10, { adjustments: { online_throttled: 0.5 } })` runs
|
||||
- **THEN** the resulting `online_throttling_100_per_hour` is half the unadjusted value
|
||||
|
||||
#### Scenario: rejects non-positive adjustment
|
||||
|
||||
- **WHEN** `crackTimes(1e10, { adjustments: { online_throttled: 0 } })` runs
|
||||
- **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.INVALID_OPTIONS"`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/scoring/crack-times.test.ts::"four scenarios at boundary guesses"` AND `packages/vault/password-strength/src/scoring/crack-times.test.ts::"applies adjustments multiplier"` AND `packages/vault/password-strength/src/scoring/crack-times.test.ts::"rejects non-positive adjustment"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Score buckets `0` through `4`
|
||||
|
||||
The package SHALL export `score(guesses): 0 | 1 | 2 | 3 | 4` per Wheeler §5: `0` when `guesses < 1e3`, `1` when `< 1e6`, `2` when `< 1e8`, `3` when `< 1e10`, `4` when `≥ 1e10`. The output type SHALL be the literal union `0 | 1 | 2 | 3 | 4` (verified at type level).
|
||||
|
||||
#### Scenario: bucket boundary 1e3
|
||||
|
||||
- **WHEN** `score(999)` and `score(1000)` are called
|
||||
- **THEN** the results are `0` and `1`
|
||||
|
||||
#### Scenario: bucket boundary 1e6
|
||||
|
||||
- **WHEN** `score(999999)` and `score(1000000)` are called
|
||||
- **THEN** the results are `1` and `2`
|
||||
|
||||
#### Scenario: bucket boundary 1e8
|
||||
|
||||
- **WHEN** `score(99999999)` and `score(100000000)` are called
|
||||
- **THEN** the results are `2` and `3`
|
||||
|
||||
#### Scenario: bucket boundary 1e10
|
||||
|
||||
- **WHEN** `score(9999999999)` and `score(10000000000)` are called
|
||||
- **THEN** the results are `3` and `4`
|
||||
|
||||
#### Scenario: type-level literal union
|
||||
|
||||
- **WHEN** `expectTypeOf(score(0)).toEqualTypeOf<0 | 1 | 2 | 3 | 4>()` runs
|
||||
- **THEN** the type-level check passes
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/scoring/score.test.ts::"bucket boundary 1e3"` AND `packages/vault/password-strength/src/scoring/score.test.ts::"bucket boundary 1e6"` AND `packages/vault/password-strength/src/scoring/score.test.ts::"bucket boundary 1e8"` AND `packages/vault/password-strength/src/scoring/score.test.ts::"bucket boundary 1e10"` AND `packages/vault/password-strength/src/scoring/score.test.ts::"score literal union type"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `assess` full-pipeline public API
|
||||
|
||||
The package SHALL export `assess(password: string, opts?: AssessOptions): StrengthResult` returning `{ password, guesses, guessesLog10, score, crackTimes, sequence, feedback, calcTimeMs }`. The pipeline SHALL: (1) Zod-validate `opts`; (2) reject input with `length > 200` via `PWSTRENGTH.INPUT_TOO_LONG`; (3) prepend `opts.userInputs` (if any) to the dictionary set as a synthetic `user_inputs` ranked dictionary; (4) run all six pattern matchers; (5) call `mostGuessableMatchSequence`; (6) call `crackTimes`; (7) call `score`; (8) call `feedback`; (9) record OTel histogram and counter; (10) return result. The DoS-guard boundary SHALL be exclusive of 200 (199 accepted, 200 rejected).
|
||||
|
||||
#### Scenario: full pipeline returns canonical shape
|
||||
|
||||
- **WHEN** `assess("Password1!")` runs
|
||||
- **THEN** the result is shaped per `StrengthResultSchema` and `score` is `≤ 1`
|
||||
|
||||
#### Scenario: 199-character input is accepted
|
||||
|
||||
- **WHEN** `assess("a".repeat(199))` runs
|
||||
- **THEN** the call returns a `StrengthResult` (no throw)
|
||||
|
||||
#### Scenario: 200-character input is rejected
|
||||
|
||||
- **WHEN** `assess("a".repeat(200))` runs
|
||||
- **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.INPUT_TOO_LONG"`
|
||||
|
||||
#### Scenario: userInputs surface as ranked matches
|
||||
|
||||
- **WHEN** `assess("hello-acme-corp", { userInputs: ["acme corp"] })` runs
|
||||
- **THEN** the resulting `sequence` contains a match with `dictionaryName="user_inputs"`
|
||||
|
||||
#### Scenario: custom dictionaries take precedence
|
||||
|
||||
- **WHEN** `assess("trustno1", { dictionaries: loadDictionaries({ passwords: new Map([["trustno1", 1]]) }) })` runs
|
||||
- **THEN** the resulting `score` is `0`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/assess/assess.test.ts::"full pipeline returns canonical shape"` AND `packages/vault/password-strength/src/assess/assess.test.ts::"accepts 199-character input"` AND `packages/vault/password-strength/src/assess/assess.test.ts::"rejects 200-character input"` AND `packages/vault/password-strength/src/assess/assess.test.ts::"userInputs surface as ranked matches"` AND `packages/vault/password-strength/src/assess/assess.test.ts::"custom dictionaries take precedence"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `feedback` keyed-string generator with i18n resolver
|
||||
|
||||
The package SHALL export `feedback(result: StrengthResult, opts?: FeedbackOptions): { warning: string; suggestions: string[] }` returning a warning + 0..N suggestions. Strings SHALL be obtained by calling `opts.resolver(key, values)` when supplied; the package SHALL fall back to a built-in English message map when `opts.resolver` is undefined or returns `undefined` for a key. The warning SHALL be keyed off the longest-cover pattern in `result.sequence` (e.g. `"feedback.warning.dictionary.top10"` for `result.sequence[0]` being a top-10 password). When the resolver throws, the package SHALL throw `PWSTRENGTH.RESOLVER_FAILED` with `cause` preserved.
|
||||
|
||||
#### Scenario: dispatches to resolver
|
||||
|
||||
- **GIVEN** a result whose longest match is a top-10 password
|
||||
- **AND** `opts.resolver` returns `"RESOLVED"` for the corresponding key
|
||||
- **WHEN** `feedback(result, opts)` runs
|
||||
- **THEN** the resulting `warning` equals `"RESOLVED"`
|
||||
|
||||
#### Scenario: falls back to English when resolver is undefined
|
||||
|
||||
- **GIVEN** a result whose longest match is a top-10 password
|
||||
- **WHEN** `feedback(result)` runs (no resolver)
|
||||
- **THEN** the resulting `warning` is the built-in English string for that key
|
||||
|
||||
#### Scenario: resolver throw surfaces RESOLVER_FAILED
|
||||
|
||||
- **WHEN** `opts.resolver` throws an `Error("boom")`
|
||||
- **THEN** `feedback(result, opts)` throws `PasswordStrengthError` with code `"PWSTRENGTH.RESOLVER_FAILED"` and `cause.message === "boom"`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/feedback/feedback.test.ts::"dispatches to resolver"` AND `packages/vault/password-strength/src/feedback/feedback.test.ts::"falls back to English when resolver undefined"` AND `packages/vault/password-strength/src/feedback/feedback.test.ts::"resolver throw surfaces RESOLVER_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `loadDictionaries` runtime swap (no global mutation)
|
||||
|
||||
The package SHALL export `loadDictionaries(dicts: PartialDictionaryMap): DictionarySet` returning a fresh `DictionarySet` value with the supplied dictionaries merged over the bundled defaults. The bundled default dictionaries SHALL NOT be mutated. The function SHALL throw `PWSTRENGTH.DICTIONARY_INVALID` when any entry has a non-positive `rank` or non-string token.
|
||||
|
||||
#### Scenario: returns a fresh DictionarySet
|
||||
|
||||
- **WHEN** `loadDictionaries({ passwords: new Map([["alpha", 1]]) })` is called twice
|
||||
- **THEN** the two returned values are distinct object references
|
||||
|
||||
#### Scenario: does not mutate bundled defaults
|
||||
|
||||
- **GIVEN** the default English dictionary set is captured before the call
|
||||
- **WHEN** `loadDictionaries({ passwords: new Map([["alpha", 1]]) })` runs
|
||||
- **THEN** the captured default dictionary set is byte-equal to its post-call value
|
||||
|
||||
#### Scenario: rejects non-positive rank
|
||||
|
||||
- **WHEN** `loadDictionaries({ passwords: new Map([["alpha", 0]]) })` runs
|
||||
- **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.DICTIONARY_INVALID"`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/dicts/load-dictionaries.test.ts::"returns a fresh DictionarySet"` AND `packages/vault/password-strength/src/dicts/load-dictionaries.test.ts::"does not mutate bundled defaults"` AND `packages/vault/password-strength/src/dicts/load-dictionaries.test.ts::"rejects non-positive rank"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `userInputs` synthetic dictionary
|
||||
|
||||
The package SHALL accept `opts.userInputs: string[]` on `assess`. Every supplied input SHALL contribute to a synthetic `user_inputs` ranked dictionary (rank assigned by input order). Matches against `user_inputs` SHALL surface in `feedback` with the personal-info warning key (`feedback.warning.personalInfo`).
|
||||
|
||||
#### Scenario: userInputs become a ranked dictionary
|
||||
|
||||
- **WHEN** `assess("alice@example.com-pwd", { userInputs: ["alice@example.com"] })` runs
|
||||
- **THEN** `result.sequence` contains a match with `dictionaryName="user_inputs"` and `matchedWord="alice@example.com"`
|
||||
|
||||
#### Scenario: feedback surfaces personalInfo key
|
||||
|
||||
- **WHEN** the longest-cover match in `result.sequence` has `dictionaryName="user_inputs"`
|
||||
- **THEN** `feedback(result)` returns a warning equal to the resolver's value for `"feedback.warning.personalInfo"` (or the English fallback)
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/assess/assess.test.ts::"userInputs become a ranked dictionary"` AND `packages/vault/password-strength/src/feedback/feedback.test.ts::"surfaces personalInfo warning key"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Multilingual dictionary subpaths
|
||||
|
||||
The package SHALL ship Wikipedia top-30 000 dictionaries for `en` (default), `de`, `fr`, `es`, `zh-Hans`, `ru` under subpath exports `./dicts/<locale>`. Each subpath SHALL export a `DictionarySet`-shaped value usable directly with `assess(pwd, { dictionaries: <imported> })`.
|
||||
|
||||
#### Scenario: every locale subpath resolves
|
||||
|
||||
- **WHEN** every subpath in `./dicts/{en,de,fr,es,zh-Hans,ru}` is imported
|
||||
- **THEN** every import resolves with no `Module not found` errors AND every imported value satisfies `DictionarySetSchema`
|
||||
|
||||
#### Scenario: locale dictionary plugs into assess
|
||||
|
||||
- **WHEN** `assess("passwort", { dictionaries: deDictionaries })` runs (where `passwort` is rank 1 in the `de` bundle)
|
||||
- **THEN** `result.score` is `0`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/dicts/dicts.test.ts::"every locale subpath resolves"` AND `packages/vault/password-strength/src/dicts/dicts.test.ts::"locale dictionary plugs into assess"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Online-attack adjustment
|
||||
|
||||
The package SHALL accept `opts.adjustments` on `crackTimes` allowing site operators to multiply each scenario's effective rate by a positive number (e.g. `{ online_throttled: 0.5 }` to model a stricter rate-limit policy halving the attacker's online throughput).
|
||||
|
||||
#### Scenario: halves online_throttled time
|
||||
|
||||
- **WHEN** `crackTimes(1e6, { adjustments: { online_throttled: 0.5 } })` runs
|
||||
- **THEN** the resulting `online_throttling_100_per_hour` equals half the unadjusted value
|
||||
|
||||
#### Scenario: every scenario is independently adjustable
|
||||
|
||||
- **WHEN** `crackTimes(1e6, { adjustments: { offline_fast: 2 } })` runs
|
||||
- **THEN** only `offline_fast_hashing_1e10_per_second` is affected; the other three scenarios match the unadjusted values
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/scoring/crack-times.test.ts::"halves online_throttled time"` AND `packages/vault/password-strength/src/scoring/crack-times.test.ts::"adjusts each scenario independently"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `assessReactive` streaming wrapper
|
||||
|
||||
The package SHALL export `assessReactive(input$: AsyncIterable<string>, opts?: AssessReactiveOptions): AsyncIterable<StrengthResult>` debouncing input emissions by `opts.debounceMs` (default 150) and cancelling any in-flight `assess` when a newer input arrives. Cancelled assessments SHALL NOT yield a result. The wrapper SHALL honour `opts.signal: AbortSignal`; aborted iteration SHALL complete cleanly with no throw and no further yields.
|
||||
|
||||
#### Scenario: debounces rapid inputs
|
||||
|
||||
- **WHEN** an input stream emits `["p", "pa", "pas", "pass"]` within `< debounceMs`
|
||||
- **THEN** the output stream yields exactly one result for `"pass"`
|
||||
|
||||
#### Scenario: cancels in-flight on newer input
|
||||
|
||||
- **WHEN** an input emits `"first"`, `assess` for `"first"` is in flight, and `"second"` is emitted
|
||||
- **THEN** the output stream yields a result only for `"second"` (no result for `"first"`)
|
||||
|
||||
#### Scenario: AbortSignal completes cleanly
|
||||
|
||||
- **GIVEN** `opts.signal` is aborted mid-stream
|
||||
- **WHEN** the consumer awaits the next iteration
|
||||
- **THEN** the iterable completes (`done: true`) with no thrown error
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/assess/assess-reactive.test.ts::"debounces rapid inputs"` AND `packages/vault/password-strength/src/assess/assess-reactive.test.ts::"cancels in-flight on newer input"` AND `packages/vault/password-strength/src/assess/assess-reactive.test.ts::"AbortSignal completes cleanly"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `INPUT_TOO_LONG` DoS guard
|
||||
|
||||
The package SHALL reject any input string with `length > 200` from `assess` and `assessReactive` by throwing `PWSTRENGTH.INPUT_TOO_LONG`. The boundary is exclusive: 199 accepted, 200 rejected.
|
||||
|
||||
#### Scenario: 199-character input accepted
|
||||
|
||||
- **WHEN** `assess("a".repeat(199))` runs
|
||||
- **THEN** the call returns a `StrengthResult`
|
||||
|
||||
#### Scenario: 200-character input rejected
|
||||
|
||||
- **WHEN** `assess("a".repeat(200))` runs
|
||||
- **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.INPUT_TOO_LONG"`
|
||||
|
||||
#### Scenario: assessReactive enforces the same bound
|
||||
|
||||
- **WHEN** an input stream emits `"a".repeat(200)`
|
||||
- **THEN** the consumer receives a thrown `PasswordStrengthError` with code `"PWSTRENGTH.INPUT_TOO_LONG"`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/assess/assess.test.ts::"accepts 199-character input"` AND `packages/vault/password-strength/src/assess/assess.test.ts::"rejects 200-character input"` AND `packages/vault/password-strength/src/assess/assess-reactive.test.ts::"rejects 200-character input"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error code registry
|
||||
|
||||
The package SHALL declare an error registry via `defineCodes("PWSTRENGTH", […])` containing exactly: `INPUT_TOO_LONG`, `INVALID_OPTIONS`, `DICTIONARY_INVALID`, `GRAPH_INVALID`, `RESOLVER_FAILED`, `ABORTED`. Every code SHALL be thrown by at least one production path; `ABORTED` MAY be surfaced as the iteration-completion reason on `assessReactive` rather than as a thrown error, but SHALL be referenced from production code (per `openspec/AGENTS.md` §2c "wired in, not deleted").
|
||||
|
||||
#### Scenario: every code thrown by ≥1 production path
|
||||
|
||||
- **WHEN** the test suite drives each of the five throw-paths and the one signal-cancellation path
|
||||
- **THEN** every code in the registry is observed at least once
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/errors/error-codes.test.ts::"every code is thrown by a production path"` AND `packages/vault/password-strength/src/errors/error-codes.test.ts::"ABORTED is surfaced from assessReactive cancellation"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary schemas
|
||||
|
||||
The package SHALL export Zod schemas at every public boundary: `AssessOptionsSchema`, `FeedbackOptionsSchema`, `AssessReactiveOptionsSchema`, `CrackTimeOptionsSchema`, `MatchSchema` (discriminated union), `StrengthResultSchema`, `DictionarySetSchema`. Every schema SHALL throw `PWSTRENGTH.INVALID_OPTIONS` (wrapping `ZodError`) when validation fails. Public functions SHALL Zod-parse their options before any other work.
|
||||
|
||||
#### Scenario: rejects malformed AssessOptions
|
||||
|
||||
- **WHEN** `assess("pwd", { userInputs: 42 } as never)` runs
|
||||
- **THEN** the call throws `PasswordStrengthError` with code `"PWSTRENGTH.INVALID_OPTIONS"`
|
||||
|
||||
#### Scenario: MatchSchema discriminates over pattern types
|
||||
|
||||
- **WHEN** `MatchSchema.parse({ pattern: "dictionary", i: 0, j: 7, token: "password", matchedWord: "password", rank: 1, dictionaryName: "passwords", l33t: false, reversed: false })` runs
|
||||
- **THEN** the parse succeeds
|
||||
|
||||
#### Scenario: rejects unknown pattern type
|
||||
|
||||
- **WHEN** `MatchSchema.parse({ pattern: "unknown" } as never)` runs
|
||||
- **THEN** the parse throws `ZodError`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/schemas/schemas.test.ts::"rejects malformed AssessOptions"` AND `packages/vault/password-strength/src/schemas/schemas.test.ts::"MatchSchema discriminates over pattern types"` AND `packages/vault/password-strength/src/schemas/schemas.test.ts::"MatchSchema rejects unknown pattern type"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel observability
|
||||
|
||||
The package SHALL emit the following instruments via `@opentelemetry/api`: `pwstrength.assess_duration_ms{score}` histogram (recorded once per `assess` call), `pwstrength.score_distribution_total{score}` counter (incremented once per `assess` call), `pwstrength.errors_total{code}` counter (incremented once per thrown `PWSTRENGTH.*` code), `pwstrength.matchers_run_total{matcher}` counter (incremented once per match found, by matcher name).
|
||||
|
||||
#### Scenario: assess emits duration histogram and score counter
|
||||
|
||||
- **GIVEN** an in-memory `MeterProvider` registered as the global meter provider
|
||||
- **WHEN** `assess("Password1!")` runs
|
||||
- **THEN** the recorded data contains exactly one `pwstrength.assess_duration_ms` data point with `score` matching the result AND one `pwstrength.score_distribution_total` increment with the same `score`
|
||||
|
||||
#### Scenario: throws emit errors_total counter
|
||||
|
||||
- **WHEN** `assess("a".repeat(200))` throws
|
||||
- **THEN** the recorded data contains a `pwstrength.errors_total{code="PWSTRENGTH.INPUT_TOO_LONG"}` increment
|
||||
|
||||
#### Scenario: matcher runs emit matchers_run_total counter
|
||||
|
||||
- **WHEN** `assess("abcd1234")` runs
|
||||
- **THEN** the recorded data contains `pwstrength.matchers_run_total` increments with `matcher="sequence"` and `matcher="sequence"` (one per matched substring)
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/observability/metrics.test.ts::"assess emits duration histogram and score counter"` AND `packages/vault/password-strength/src/observability/metrics.test.ts::"throws emit errors_total counter"` AND `packages/vault/password-strength/src/observability/metrics.test.ts::"matcher runs emit matchers_run_total counter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Property tests — matcher commutativity and score-bucket monotonicity
|
||||
|
||||
The package SHALL include fast-check property suites pinning: (1) **matcher commutativity** — running matchers in any permutation produces identical `mostGuessableMatchSequence` output; (2) **score monotonicity** — `g1 ≤ g2 → score(g1) ≤ score(g2)`; (3) **`crackTimes` monotonicity** — every scenario value is monotonic in `guesses`; (4) **idempotence** — `assess(p)` called twice on the same input returns deeply-equal results; (5) **DoS-guard boundary** — `length ≤ 199` accepts, `length ≥ 200` rejects, with no exception leaks.
|
||||
|
||||
#### Scenario: matcher commutativity
|
||||
|
||||
- **WHEN** fast-check generates a password and runs the matcher set in randomised order
|
||||
- **THEN** the resulting `MatchSequence` (sorted) is byte-equal across permutations
|
||||
|
||||
#### Scenario: score-bucket monotonicity
|
||||
|
||||
- **WHEN** fast-check generates `(g1, g2)` with `g1 ≤ g2`
|
||||
- **THEN** `score(g1) ≤ score(g2)` holds
|
||||
|
||||
#### Scenario: idempotence
|
||||
|
||||
- **WHEN** fast-check generates a password ≤199 chars
|
||||
- **THEN** `assess(p)` called twice returns deeply-equal results (excluding `calcTimeMs`)
|
||||
|
||||
#### Scenario: DoS-guard boundary holds across all inputs
|
||||
|
||||
- **WHEN** fast-check generates strings of every length in `[0, 250]`
|
||||
- **THEN** `length < 200` always returns; `length ≥ 200` always throws `PWSTRENGTH.INPUT_TOO_LONG`
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/properties.test.ts::"matcher commutativity"` AND `packages/vault/password-strength/src/properties.test.ts::"score-bucket monotonicity"` AND `packages/vault/password-strength/src/properties.test.ts::"crackTimes monotonicity"` AND `packages/vault/password-strength/src/properties.test.ts::"assess idempotence"` AND `packages/vault/password-strength/src/properties.test.ts::"DoS-guard boundary"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports
|
||||
|
||||
The package SHALL declare subpath exports so consumers can tree-shake the surface they need:
|
||||
|
||||
- `@suaveplan/password-strength` — root barrel
|
||||
- `@suaveplan/password-strength/assess` — `assess`, `assessReactive`, types
|
||||
- `@suaveplan/password-strength/feedback` — `feedback`, types
|
||||
- `@suaveplan/password-strength/scoring` — `mostGuessableMatchSequence`, `estimateGuesses`, `crackTimes`, `score`
|
||||
- `@suaveplan/password-strength/matchers/{dictionary,sequence,repeat,date,keyboard,regex}`
|
||||
- `@suaveplan/password-strength/dicts/{en,de,fr,es,zh-Hans,ru}`
|
||||
- `@suaveplan/password-strength/graphs/{qwerty,dvorak,azerty}`
|
||||
- `@suaveplan/password-strength/errors`
|
||||
- `@suaveplan/password-strength/schemas`
|
||||
|
||||
#### Scenario: every subpath resolves
|
||||
|
||||
- **WHEN** every documented subpath is imported in a consumer test fixture
|
||||
- **THEN** every import resolves with no `Module not found` errors
|
||||
|
||||
**Verified by:** `packages/vault/password-strength/src/index.test.ts::"every documented subpath export resolves"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,136 @@
|
||||
# Tasks — `@suaveplan/password-strength` (zxcvbn-style password strength estimator)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read Wheeler 2016 USENIX paper end-to-end; record line-anchored citations into per-pattern `.md` files
|
||||
- [x] 1.2 Read `dropbox/zxcvbn` reference impl (matching.coffee, scoring.coffee, time_estimates.coffee, feedback.coffee, frequency_lists.coffee, adjacency_graphs.coffee)
|
||||
- [x] 1.3 Read `../suaveplan/enterprise/password-strength.md` end-to-end
|
||||
- [x] 1.4 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.5 `proposal.md` authored from `openspec/templates/proposal.md`
|
||||
- [x] 1.6 `## Source mapping` table populated; "no source line" justifications written
|
||||
- [x] 1.7 `specs/password-strength/spec.md` delta drafted from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.8 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [x] 2.1 `packages/vault/password-strength/src/{matchers,scoring,assess,feedback,dicts,graphs,errors,schemas,observability,__benchmarks__}/` structure
|
||||
- [x] 2.2 `package.json` — `@suaveplan/password-strength@0.0.0`, `type: module`, `sideEffects: false`, deps via `workspace:^` / `catalog:*`
|
||||
- [x] 2.3 `tsconfig.lib.json` extends `@suaveplan/typescript-config/library`
|
||||
- [x] 2.4 `vite.config.ts` + `vitest.config.ts` (uses `createTestConfig()`)
|
||||
- [x] 2.5 `README.md` with Modules table linking every co-located `.md`
|
||||
- [x] 2.6 Subpath `exports` map covers root + every feature subpath listed in the proposal
|
||||
|
||||
## 3. Implementation — pattern matchers
|
||||
|
||||
- [x] 3.1 `src/matchers/dictionary/dictionary.ts` — frequency-ranked lookup, l33t enumeration, reverse-string lookup
|
||||
- [x] 3.2 `src/matchers/sequence/sequence.ts` — alphabetical + numeric, both directions, length ≥ 2
|
||||
- [x] 3.3 `src/matchers/repeat/repeat.ts` — greedy chunk regex; recursive `baseGuesses`
|
||||
- [x] 3.4 `src/matchers/date/date.ts` — separator + no-separator; `1900 ≤ year ≤ now + 50`
|
||||
- [x] 3.5 `src/matchers/keyboard/keyboard.ts` — adjacency-graph walk; `turns` + `shiftedCount`; pluggable graph
|
||||
- [x] 3.6 `src/matchers/regex/regex.ts` — recent-year + registry-pluggable
|
||||
|
||||
## 4. Implementation — bundled dictionaries
|
||||
|
||||
- [x] 4.1 `src/dicts/en/{passwords,english_wikipedia,male_names,female_names,surnames}.ts` — frozen `Map<string, number>` rank tables
|
||||
- [x] 4.2 `src/dicts/de/`, `src/dicts/fr/`, `src/dicts/es/`, `src/dicts/zh-Hans/`, `src/dicts/ru/` — Wikipedia top-30k per locale
|
||||
- [x] 4.3 `src/dicts/load-dictionaries.ts` — runtime swap returning fresh `DictionarySet`; `DICTIONARY_INVALID` on bad rank/token
|
||||
|
||||
## 5. Implementation — keyboard graphs
|
||||
|
||||
- [x] 5.1 `src/graphs/qwerty/qwerty.ts` — QWERTY adjacency table
|
||||
- [x] 5.2 `src/graphs/dvorak/dvorak.ts` — DVORAK adjacency table
|
||||
- [x] 5.3 `src/graphs/azerty/azerty.ts` — AZERTY adjacency table
|
||||
- [x] 5.4 `src/graphs/validate-graph.ts` — `GRAPH_INVALID` on self-adjacency / non-symmetric edges
|
||||
|
||||
## 6. Implementation — scoring
|
||||
|
||||
- [x] 6.1 `src/scoring/most-guessable-match-sequence.ts` — `n^2` DP; lowest-product cover; factorial penalty; fewest-matches tie-break
|
||||
- [x] 6.2 `src/scoring/estimate-guesses.ts` — per-pattern closed forms (dictionary, sequence, repeat, date, keyboard, regex)
|
||||
- [x] 6.3 `src/scoring/crack-times.ts` — four scenarios; `adjustments` multiplier map
|
||||
- [x] 6.4 `src/scoring/score.ts` — five-bucket score function
|
||||
|
||||
## 7. Implementation — public API
|
||||
|
||||
- [x] 7.1 `src/assess/assess.ts` — full pipeline; `userInputs`; custom dictionaries; 200-char DoS guard with `INPUT_TOO_LONG`
|
||||
- [x] 7.2 `src/assess/assess-reactive.ts` — debounce + cancel-on-newer; `AbortSignal` threading; `ABORTED` on cancellation
|
||||
- [x] 7.3 `src/feedback/feedback.ts` — keyed strings via injected resolver; English fallback; `RESOLVER_FAILED` on resolver throw
|
||||
|
||||
## 8. Implementation — errors + schemas + observability
|
||||
|
||||
- [x] 8.1 `src/errors/error-codes.ts` — `defineCodes("PWSTRENGTH", […])` with 6 codes; `PasswordStrengthError`
|
||||
- [x] 8.2 `src/schemas/*.ts` — `AssessOptionsSchema`, `FeedbackOptionsSchema`, `AssessReactiveOptionsSchema`, `CrackTimeOptionsSchema`, `MatchSchema`, `StrengthResultSchema`
|
||||
- [x] 8.3 `src/observability/metrics.ts` — `pwstrength.assess_duration_ms`, `pwstrength.score_distribution_total`, `pwstrength.errors_total`, `pwstrength.matchers_run_total`
|
||||
|
||||
## 9. Root barrel + subpath exports
|
||||
|
||||
- [x] 9.1 `src/index.ts` — explicit named re-exports (no `export *`)
|
||||
- [x] 9.2 Every feature dir has its own barrel using explicit named exports
|
||||
- [x] 9.3 Subpath export count in `package.json` matches feature module count
|
||||
|
||||
## 10. Documentation
|
||||
|
||||
- [x] 10.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference, Wheeler line citation in Implementation Notes)
|
||||
- [x] 10.2 README links every co-located `.md` under `## Modules`
|
||||
- [x] 10.3 CLAUDE.md package listing updated under `#### Vault orchestration` (or appropriate section)
|
||||
|
||||
## 11. Tests
|
||||
|
||||
- [x] 11.1 `src/matchers/dictionary/dictionary.test.ts` — l33t / reverse / rank / every bundled dict
|
||||
- [x] 11.2 `src/matchers/sequence/sequence.test.ts` — ascending + descending; lower/upper/digits
|
||||
- [x] 11.3 `src/matchers/repeat/repeat.test.ts` — single + multi-char chunks; recursive `baseGuesses`
|
||||
- [x] 11.4 `src/matchers/date/date.test.ts` — separator + no-separator; year bounds; ambiguous-parse preference
|
||||
- [x] 11.5 `src/matchers/keyboard/keyboard.test.ts` — QWERTY walks; turns; shifted-count; DVORAK + AZERTY swap
|
||||
- [x] 11.6 `src/matchers/regex/regex.test.ts` — recent-year; custom regex registration
|
||||
- [x] 11.7 `src/scoring/most-guessable-match-sequence.test.ts` — empty / single / multi cover; factorial; fewest-matches tie-break; recursive repeat
|
||||
- [x] 11.8 `src/scoring/estimate-guesses.test.ts` — closed-form per pattern; corpus agreement ≤1 bit log10
|
||||
- [x] 11.9 `src/scoring/crack-times.test.ts` — four scenarios; `adjustments`; humanized strings
|
||||
- [x] 11.10 `src/scoring/score.test.ts` — every bucket boundary (`1e3 - 1`, `1e3`, `1e6 - 1`, `1e6`, …, `1e10`)
|
||||
- [x] 11.11 `src/assess/assess.test.ts` — full pipeline; `userInputs`; custom dictionaries; 199-char accept / 200-char reject
|
||||
- [x] 11.12 `src/assess/assess-reactive.test.ts` — debounce; cancel-on-newer; `AbortSignal`
|
||||
- [x] 11.13 `src/feedback/feedback.test.ts` — resolver dispatch; English fallback; `RESOLVER_FAILED`
|
||||
- [x] 11.14 `src/errors/error-codes.test.ts` — every code thrown by ≥1 production path
|
||||
- [x] 11.15 `src/observability/metrics.test.ts` — every instrument recorded against in-memory `MeterProvider`
|
||||
- [x] 11.16 `src/schemas/schemas.test.ts` — every schema rejects malformed input with `INVALID_OPTIONS`
|
||||
- [x] 11.17 `src/conformance.test.ts` — vendored Wheeler corpus (~120 vectors) agrees to ≤1 bit log10 and exact `score`
|
||||
- [x] 11.18 `src/properties.test.ts` — fast-check: matcher commutativity, score-bucket monotonicity, `crackTimes` monotonicity, idempotence, `INPUT_TOO_LONG` boundary
|
||||
- [x] 11.19 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
- [x] 11.20 Runner-neutral imports — `grep` for direct `vitest` / `bun:test` imports returns zero
|
||||
|
||||
## 12. Benchmarks
|
||||
|
||||
- [x] 12.1 `src/__benchmarks__/assess_short_password.bench.ts`
|
||||
- [x] 12.2 `src/__benchmarks__/assess_long_password.bench.ts`
|
||||
- [x] 12.3 `src/__benchmarks__/assess_dictionary_heavy.bench.ts`
|
||||
- [x] 12.4 `src/__benchmarks__/dp_n100.bench.ts`
|
||||
- [x] 12.5 `src/__benchmarks__/dp_n200.bench.ts`
|
||||
- [x] 12.6 README publishes baseline numbers from a single run
|
||||
|
||||
## 13. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 13.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
|
||||
- [x] 13.2 `bunx turbo lint typecheck test --filter=@suaveplan/password-strength` — green
|
||||
- [x] 13.3 `bunx turbo test:coverage --filter=@suaveplan/password-strength` — 100/100/100/100
|
||||
- [x] 13.4 `bun scripts/openspec-conformance.ts @suaveplan/password-strength` — exit 0, no `error` findings
|
||||
- [x] 13.5 `bunx turbo build --filter=@suaveplan/password-strength` — green
|
||||
|
||||
## 14. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [x] 14.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (Match discriminated union, score literal-`0|1|2|3|4`)
|
||||
- [x] 14.2 Zod schemas wired at every declared boundary (`assess`, `feedback`, `assessReactive`, `crackTimes`, `loadDictionaries`)
|
||||
- [x] 14.3 Every error code in `error-codes.ts` thrown by ≥1 production path (asserted in `error-codes.test.ts`)
|
||||
- [x] 14.4 Strict-mode behaviors tested both on and off (`escapeHtml`, `INPUT_TOO_LONG` boundary)
|
||||
- [x] 14.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [x] 14.6 Lifecycle dedup tested where claimed — `assessReactive` cancel-on-newer dedups in-flight compute
|
||||
- [x] 14.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 15. Archive
|
||||
|
||||
- [x] 15.1 Fold spec delta into `openspec/specs/password-strength/spec.md`
|
||||
- [x] 15.2 Move `openspec/changes/enterprise-password-strength/` → `openspec/archive/<yyyy-mm-dd>-enterprise-password-strength/`
|
||||
- [x] 15.3 Refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`)
|
||||
- [x] 15.4 Refresh `MISSING.yml` (`bun scripts/generate-missing.ts`)
|
||||
- [x] 15.5 Update `STATUS.md` row from `Pending` → `Completed`
|
||||
- [x] 15.6 Update `SUMMARY.md` per-package paragraph
|
||||
- [x] 15.7 `bunx markdownlint-cli2 --fix` clean across touched files
|
||||
Reference in New Issue
Block a user