Initial Commit
This commit is contained in:
@@ -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 -->
|
||||
Reference in New Issue
Block a user