Files
suaveplan/openspec/specs/password-strength/spec.md
2026-05-27 23:29:53 +01:00

563 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a plain dictionary token"` AND `packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a l33t-substituted token"` AND `packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a reversed token"` AND `packages/crypto/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/crypto/password-strength/src/matchers/sequence/sequence.test.ts::"matches ascending alphabetical run"` AND `packages/crypto/password-strength/src/matchers/sequence/sequence.test.ts::"matches descending numeric run"` AND `packages/crypto/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/crypto/password-strength/src/matchers/repeat/repeat.test.ts::"matches single-character repeat"` AND `packages/crypto/password-strength/src/matchers/repeat/repeat.test.ts::"matches multi-character repeat"` AND `packages/crypto/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/crypto/password-strength/src/matchers/date/date.test.ts::"matches separator date"` AND `packages/crypto/password-strength/src/matchers/date/date.test.ts::"matches no-separator date"` AND `packages/crypto/password-strength/src/matchers/date/date.test.ts::"rejects out-of-range year"` AND `packages/crypto/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/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"matches QWERTY straight-line walk"` AND `packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts turns on direction change"` AND `packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts shifted keys"` AND `packages/crypto/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/crypto/password-strength/src/matchers/regex/regex.test.ts::"matches recent year"` AND `packages/crypto/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/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"empty input returns 1 guess"` AND `packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"single-match cover"` AND `packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"prefers fewer-match cover on tie"` AND `packages/crypto/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/crypto/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary closed form"` AND `packages/crypto/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary reverse and l33t multipliers"` AND `packages/crypto/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/crypto/password-strength/src/scoring/crack-times.test.ts::"four scenarios at boundary guesses"` AND `packages/crypto/password-strength/src/scoring/crack-times.test.ts::"applies adjustments multiplier"` AND `packages/crypto/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/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e3"` AND `packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e6"` AND `packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e8"` AND `packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e10"` AND `packages/crypto/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/crypto/password-strength/src/assess/assess.test.ts::"full pipeline returns canonical shape"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"accepts 199-character input"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"rejects 200-character input"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"userInputs surface as ranked matches"` AND `packages/crypto/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/crypto/password-strength/src/feedback/feedback.test.ts::"dispatches to resolver"` AND `packages/crypto/password-strength/src/feedback/feedback.test.ts::"falls back to English when resolver undefined"` AND `packages/crypto/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/crypto/password-strength/src/dicts/load-dictionaries.test.ts::"returns a fresh DictionarySet"` AND `packages/crypto/password-strength/src/dicts/load-dictionaries.test.ts::"does not mutate bundled defaults"` AND `packages/crypto/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/crypto/password-strength/src/assess/assess.test.ts::"userInputs become a ranked dictionary"` AND `packages/crypto/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/crypto/password-strength/src/dicts/dicts.test.ts::"every locale subpath resolves"` AND `packages/crypto/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/crypto/password-strength/src/scoring/crack-times.test.ts::"halves online_throttled time"` AND `packages/crypto/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/crypto/password-strength/src/assess/assess-reactive.test.ts::"debounces rapid inputs"` AND `packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"cancels in-flight on newer input"` AND `packages/crypto/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/crypto/password-strength/src/assess/assess.test.ts::"accepts 199-character input"` AND `packages/crypto/password-strength/src/assess/assess.test.ts::"rejects 200-character input"` AND `packages/crypto/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/crypto/password-strength/src/errors/error-codes.test.ts::"every code is thrown by a production path"` AND `packages/crypto/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/crypto/password-strength/src/schemas/schemas.test.ts::"rejects malformed AssessOptions"` AND `packages/crypto/password-strength/src/schemas/schemas.test.ts::"MatchSchema discriminates over pattern types"` AND `packages/crypto/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/crypto/password-strength/src/observability/metrics.test.ts::"assess emits duration histogram and score counter"` AND `packages/crypto/password-strength/src/observability/metrics.test.ts::"throws emit errors_total counter"` AND `packages/crypto/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/crypto/password-strength/src/properties.test.ts::"matcher commutativity"` AND `packages/crypto/password-strength/src/properties.test.ts::"score-bucket monotonicity"` AND `packages/crypto/password-strength/src/properties.test.ts::"crackTimes monotonicity"` AND `packages/crypto/password-strength/src/properties.test.ts::"assess idempotence"` AND `packages/crypto/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/crypto/password-strength/src/index.test.ts::"every documented subpath export resolves"`
<!-- markdownlint-enable MD024 -->