Initial Commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# `azerty` — AZERTY keyboard adjacency graph
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Encodes the physical adjacency of keys on the French AZERTY keyboard
|
||||
layout. AZERTY swaps `A`↔`Q` and `Z`↔`W` relative to QWERTY and reshuffles
|
||||
the punctuation column. Callers targeting French-locale passwords plug
|
||||
this graph into `keyboardMatch` to detect spatial walks like `azerty`
|
||||
or `qsdf` (the AZERTY home row).
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `KeyboardGraph` covering the AZERTY alphanumeric block
|
||||
- Slot order matches QWERTY for consumer compatibility
|
||||
- Per-key adjacency includes the unshifted and shifted character
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AZERTY_GRAPH } from "@suaveplan/password-strength/graphs/azerty";
|
||||
|
||||
console.log(AZERTY_GRAPH.a); // [null, "1!", "2é", "zZ", "qQ", null]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { keyboardMatch } from "@suaveplan/password-strength/matchers/keyboard";
|
||||
import { AZERTY_GRAPH } from "@suaveplan/password-strength/graphs/azerty";
|
||||
|
||||
const matches = keyboardMatch("azerty", { graphs: { azerty: AZERTY_GRAPH } });
|
||||
console.log(matches);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AZERTY_GRAPH: KeyboardGraph` — frozen `Record<string, ReadonlyArray<string | null>>`
|
||||
mapping each AZERTY key to its six adjacency slots.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The shifted character for digit keys uses the standard French punctuation
|
||||
mapping (`2é`, `3"`, `4'`, etc.), reflecting that French AZERTY emits the
|
||||
diacritical character without Shift and the digit with Shift. Spatial-walk
|
||||
detection treats those characters as a single press; the shifted-count
|
||||
attribute is incremented when the typed character matches the
|
||||
"shifted" half of the slot. The graph is intentionally restricted to keys
|
||||
common in real passwords to keep the data file small.
|
||||
|
||||
## Additional Implementation Notes
|
||||
|
||||
The implementation follows the canonical zxcvbn algorithm from Wheeler 2016 with
|
||||
the suaveplan-style additions described in the package proposal: structured error
|
||||
codes via the `defineCodes` registry, Zod boundaries at every public entry
|
||||
point, OTel histograms and counters keyed to low-cardinality attributes, and
|
||||
a strict input-length guard at 200 characters. Consumers integrate with their
|
||||
existing observability stack by installing a meter provider before importing
|
||||
the package, and with their i18n stack via the `MessageResolver` callback type
|
||||
declared in `@suaveplan/types`. The bundled data tables are intentionally
|
||||
small to keep the package bundle compact; production deployments should swap
|
||||
in fuller frequency-ranked tables via `loadDictionaries`. The default English
|
||||
dictionary set is a frozen `DictionarySet` that the matcher never mutates;
|
||||
caller-supplied dictionaries override defaults by name.
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { KeyboardGraph } from "../../matchers/types.js";
|
||||
|
||||
/**
|
||||
* AZERTY adjacency graph for the French keyboard layout. Slot order matches
|
||||
* QWERTY (left, top-left, top-right, right, bottom-right, bottom-left). The
|
||||
* top row swaps `A`↔`Q` and `Z`↔`W` relative to QWERTY, and the punctuation
|
||||
* column shifts accordingly.
|
||||
*/
|
||||
export const AZERTY_GRAPH: KeyboardGraph = Object.freeze({
|
||||
a: [null, "1!", "2é", "zZ", "qQ", null],
|
||||
z: ["aA", "2é", '3"', "eE", "sS", "qQ"],
|
||||
e: ["zZ", '3"', "4'", "rR", "dD", "sS"],
|
||||
r: ["eE", "4'", "5(", "tT", "fF", "dD"],
|
||||
t: ["rR", "5(", "6-", "yY", "gG", "fF"],
|
||||
y: ["tT", "6-", "7è", "uU", "hH", "gG"],
|
||||
u: ["yY", "7è", "8_", "iI", "jJ", "hH"],
|
||||
i: ["uU", "8_", "9ç", "oO", "kK", "jJ"],
|
||||
o: ["iI", "9ç", "0à", "pP", "lL", "kK"],
|
||||
p: ["oO", "0à", null, null, "mM", "lL"],
|
||||
q: [null, "aA", "zZ", "sS", "wW", null],
|
||||
s: ["qQ", "zZ", "eE", "dD", "xX", "wW"],
|
||||
d: ["sS", "eE", "rR", "fF", "cC", "xX"],
|
||||
f: ["dD", "rR", "tT", "gG", "vV", "cC"],
|
||||
g: ["fF", "tT", "yY", "hH", "bB", "vV"],
|
||||
h: ["gG", "yY", "uU", "jJ", "nN", "bB"],
|
||||
j: ["hH", "uU", "iI", "kK", ",?", "nN"],
|
||||
k: ["jJ", "iI", "oO", "lL", ";.", ",?"],
|
||||
l: ["kK", "oO", "pP", "mM", ":/", ";."],
|
||||
m: ["lL", "pP", null, null, "!§", ":/"],
|
||||
w: [null, "qQ", "sS", "xX", null, null],
|
||||
x: ["wW", "sS", "dD", "cC", null, null],
|
||||
c: ["xX", "dD", "fF", "vV", null, null],
|
||||
v: ["cC", "fF", "gG", "bB", null, null],
|
||||
b: ["vV", "gG", "hH", "nN", null, null],
|
||||
n: ["bB", "hH", "jJ", ",?", null, null],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { AZERTY_GRAPH } from "./azerty.js";
|
||||
@@ -0,0 +1,65 @@
|
||||
# `dvorak` — DVORAK keyboard adjacency graph
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Encodes the physical adjacency of keys on the DVORAK Simplified layout so
|
||||
the `keyboardMatch` matcher can detect spatial walks like `aoeu` (the home
|
||||
row) for users who type on DVORAK rather than QWERTY. Driven by Wheeler
|
||||
2016 section 4.5 keyboard-walk model.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `KeyboardGraph` covering the alphanumeric block
|
||||
- Slot order matches QWERTY (left, top-left, top-right, right, bottom-right,
|
||||
bottom-left) so consumers can substitute graphs without code changes
|
||||
- Each adjacency string encodes the unshifted and shifted character at that key
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DVORAK_GRAPH } from "@suaveplan/password-strength/graphs/dvorak";
|
||||
|
||||
console.log(DVORAK_GRAPH.a); // [null, "'\"", ",<", "oO", ";:", null]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { keyboardMatch } from "@suaveplan/password-strength/matchers/keyboard";
|
||||
import { DVORAK_GRAPH } from "@suaveplan/password-strength/graphs/dvorak";
|
||||
|
||||
// Detect DVORAK home-row walks
|
||||
const matches = keyboardMatch("aoeu", { graphs: { dvorak: DVORAK_GRAPH } });
|
||||
console.log(matches);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DVORAK_GRAPH: KeyboardGraph` — frozen `Record<string, ReadonlyArray<string | null>>`
|
||||
mapping each DVORAK key to its six adjacency slots.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The matcher treats DVORAK exactly like QWERTY for the purposes of run length,
|
||||
turns, and shifted-count counting. Symbols that DVORAK shares with QWERTY
|
||||
keep their QWERTY-side adjacencies for the symbol row; the alphanumeric
|
||||
block uses DVORAK's distinct layout. Callers can swap the graph in via the
|
||||
`keyboardMatch` `graphs` option without altering surrounding code.
|
||||
|
||||
## Additional Implementation Notes
|
||||
|
||||
The implementation follows the canonical zxcvbn algorithm from Wheeler 2016 with
|
||||
the suaveplan-style additions described in the package proposal: structured error
|
||||
codes via the `defineCodes` registry, Zod boundaries at every public entry
|
||||
point, OTel histograms and counters keyed to low-cardinality attributes, and
|
||||
a strict input-length guard at 200 characters. Consumers integrate with their
|
||||
existing observability stack by installing a meter provider before importing
|
||||
the package, and with their i18n stack via the `MessageResolver` callback type
|
||||
declared in `@suaveplan/types`. The bundled data tables are intentionally
|
||||
small to keep the package bundle compact; production deployments should swap
|
||||
in fuller frequency-ranked tables via `loadDictionaries`. The default English
|
||||
dictionary set is a frozen `DictionarySet` that the matcher never mutates;
|
||||
caller-supplied dictionaries override defaults by name.
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { KeyboardGraph } from "../../matchers/types.js";
|
||||
|
||||
/**
|
||||
* DVORAK adjacency graph for the alphanumeric block. Mirrors the upstream
|
||||
* zxcvbn reference impl's encoding (`adjacency_graphs.coffee`), restricted to
|
||||
* the keys most likely to appear in a password. Slot order matches QWERTY:
|
||||
* left, top-left, top-right, right, bottom-right, bottom-left.
|
||||
*/
|
||||
export const DVORAK_GRAPH: KeyboardGraph = Object.freeze({
|
||||
"'": [null, "1!", "2@", ",<", "aA", null],
|
||||
",": ["'\"", "2@", "3#", ".>", "oO", "aA"],
|
||||
".": [",<", "3#", "4$", "pP", "eE", "oO"],
|
||||
p: [".>", "4$", "5%", "yY", "uU", "eE"],
|
||||
y: ["pP", "5%", "6^", "fF", "iI", "uU"],
|
||||
f: ["yY", "6^", "7&", "gG", "dD", "iI"],
|
||||
g: ["fF", "7&", "8*", "cC", "hH", "dD"],
|
||||
c: ["gG", "8*", "9(", "rR", "tT", "hH"],
|
||||
r: ["cC", "9(", "0)", "lL", "nN", "tT"],
|
||||
l: ["rR", "0)", "[{", "/?", "sS", "nN"],
|
||||
a: [null, "'\"", ",<", "oO", ";:", null],
|
||||
o: ["aA", ",<", ".>", "eE", "qQ", ";:"],
|
||||
e: ["oO", ".>", "pP", "uU", "jJ", "qQ"],
|
||||
u: ["eE", "pP", "yY", "iI", "kK", "jJ"],
|
||||
i: ["uU", "yY", "fF", "dD", "xX", "kK"],
|
||||
d: ["iI", "fF", "gG", "hH", "bB", "xX"],
|
||||
h: ["dD", "gG", "cC", "tT", "mM", "bB"],
|
||||
t: ["hH", "cC", "rR", "nN", "wW", "mM"],
|
||||
n: ["tT", "rR", "lL", "sS", "vV", "wW"],
|
||||
s: ["nN", "lL", "/?", "-_", "zZ", "vV"],
|
||||
";": [null, "aA", "oO", "qQ", null, null],
|
||||
q: [";:", "oO", "eE", "jJ", null, null],
|
||||
j: ["qQ", "eE", "uU", "kK", null, null],
|
||||
k: ["jJ", "uU", "iI", "xX", null, null],
|
||||
x: ["kK", "iI", "dD", "bB", null, null],
|
||||
b: ["xX", "dD", "hH", "mM", null, null],
|
||||
m: ["bB", "hH", "tT", "wW", null, null],
|
||||
w: ["mM", "tT", "nN", "vV", null, null],
|
||||
v: ["wW", "nN", "sS", "zZ", null, null],
|
||||
z: ["vV", "sS", "-_", null, null, null],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { DVORAK_GRAPH } from "./dvorak.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { QWERTY_GRAPH } from "./qwerty.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# `qwerty` — QWERTY keyboard adjacency graph
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Encodes the physical adjacency of keys on a standard US QWERTY layout so
|
||||
the `keyboardMatch` matcher can recognise spatial walks like `qwerty`,
|
||||
`asdf`, and `1qaz2wsx`. The graph drives the keyboard-walk guess estimate
|
||||
in Wheeler 2016 section 4.5. The encoding mirrors the upstream zxcvbn
|
||||
reference implementation's `adjacency_graphs.coffee`.
|
||||
|
||||
## Features
|
||||
|
||||
- Complete coverage of the alphanumeric, symbol, and shifted-symbol rows
|
||||
- Adjacency slots in zxcvbn's canonical order (left, top-left, top-right,
|
||||
right, bottom-right, bottom-left)
|
||||
- Each slot encodes both the unshifted and shifted character at that key
|
||||
- Frozen at module load — safe against accidental mutation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { QWERTY_GRAPH } from "@suaveplan/password-strength/graphs/qwerty";
|
||||
|
||||
console.log(QWERTY_GRAPH.q); // [null, "1!", "2@", "wW", "aA", null]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { keyboardMatch } from "@suaveplan/password-strength/matchers/keyboard";
|
||||
import { QWERTY_GRAPH } from "@suaveplan/password-strength/graphs/qwerty";
|
||||
import { DVORAK_GRAPH } from "@suaveplan/password-strength/graphs/dvorak";
|
||||
|
||||
const matches = keyboardMatch("qwerty", {
|
||||
graphs: { qwerty: QWERTY_GRAPH, dvorak: DVORAK_GRAPH },
|
||||
});
|
||||
console.log(matches);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `QWERTY_GRAPH: KeyboardGraph` — frozen `Record<string, ReadonlyArray<string | null>>`
|
||||
mapping each key to its six adjacency slots.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The matcher checks both characters of each slot when classifying a key
|
||||
press: matching against the second character flags the press as
|
||||
shift-required (`shiftedCount += 1`). Slots that point off the edge of the
|
||||
keyboard are `null`. The graph deliberately preserves the upstream slot
|
||||
order so callers porting from `zxcvbn` consume the same data layout.
|
||||
|
||||
## Additional Implementation Notes
|
||||
|
||||
The implementation follows the canonical zxcvbn algorithm from Wheeler 2016 with
|
||||
the suaveplan-style additions described in the package proposal: structured error
|
||||
codes via the `defineCodes` registry, Zod boundaries at every public entry
|
||||
point, OTel histograms and counters keyed to low-cardinality attributes, and
|
||||
a strict input-length guard at 200 characters. Consumers integrate with their
|
||||
existing observability stack by installing a meter provider before importing
|
||||
the package, and with their i18n stack via the `MessageResolver` callback type
|
||||
declared in `@suaveplan/types`. The bundled data tables are intentionally
|
||||
small to keep the package bundle compact; production deployments should swap
|
||||
in fuller frequency-ranked tables via `loadDictionaries`. The default English
|
||||
dictionary set is a frozen `DictionarySet` that the matcher never mutates;
|
||||
caller-supplied dictionaries override defaults by name.
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { KeyboardGraph } from "../../matchers/types.js";
|
||||
|
||||
/**
|
||||
* QWERTY adjacency graph. Each entry maps a key to the array of physically
|
||||
* adjacent keys in standard zxcvbn order (left, top-left, top-right, right,
|
||||
* bottom-right, bottom-left). Each adjacency string is `"<unshifted><shifted>"`;
|
||||
* empty slots are `null`. Derived from the `dropbox/zxcvbn` reference impl's
|
||||
* `data/adjacency_graphs.coffee`.
|
||||
*/
|
||||
export const QWERTY_GRAPH: KeyboardGraph = Object.freeze({
|
||||
"`": ["1!", null, null, null, null, null],
|
||||
"1": ["`~", null, null, "2@", "qQ", null],
|
||||
"2": ["1!", null, null, "3#", "wW", "qQ"],
|
||||
"3": ["2@", null, null, "4$", "eE", "wW"],
|
||||
"4": ["3#", null, null, "5%", "rR", "eE"],
|
||||
"5": ["4$", null, null, "6^", "tT", "rR"],
|
||||
"6": ["5%", null, null, "7&", "yY", "tT"],
|
||||
"7": ["6^", null, null, "8*", "uU", "yY"],
|
||||
"8": ["7&", null, null, "9(", "iI", "uU"],
|
||||
"9": ["8*", null, null, "0)", "oO", "iI"],
|
||||
"0": ["9(", null, null, "-_", "pP", "oO"],
|
||||
"-": ["0)", null, null, "=+", "[{", "pP"],
|
||||
"=": ["-_", null, null, null, "]}", "[{"],
|
||||
q: [null, "1!", "2@", "wW", "aA", null],
|
||||
w: ["qQ", "2@", "3#", "eE", "sS", "aA"],
|
||||
e: ["wW", "3#", "4$", "rR", "dD", "sS"],
|
||||
r: ["eE", "4$", "5%", "tT", "fF", "dD"],
|
||||
t: ["rR", "5%", "6^", "yY", "gG", "fF"],
|
||||
y: ["tT", "6^", "7&", "uU", "hH", "gG"],
|
||||
u: ["yY", "7&", "8*", "iI", "jJ", "hH"],
|
||||
i: ["uU", "8*", "9(", "oO", "kK", "jJ"],
|
||||
o: ["iI", "9(", "0)", "pP", "lL", "kK"],
|
||||
p: ["oO", "0)", "-_", "[{", ";:", "lL"],
|
||||
"[": ["pP", "-_", "=+", "]}", "'\"", ";:"],
|
||||
"]": ["[{", "=+", null, "\\|", null, "'\""],
|
||||
a: [null, "qQ", "wW", "sS", "zZ", null],
|
||||
s: ["aA", "wW", "eE", "dD", "xX", "zZ"],
|
||||
d: ["sS", "eE", "rR", "fF", "cC", "xX"],
|
||||
f: ["dD", "rR", "tT", "gG", "vV", "cC"],
|
||||
g: ["fF", "tT", "yY", "hH", "bB", "vV"],
|
||||
h: ["gG", "yY", "uU", "jJ", "nN", "bB"],
|
||||
j: ["hH", "uU", "iI", "kK", "mM", "nN"],
|
||||
k: ["jJ", "iI", "oO", "lL", ",<", "mM"],
|
||||
l: ["kK", "oO", "pP", ";:", ".>", ",<"],
|
||||
";": ["lL", "pP", "[{", "'\"", "/?", ".>"],
|
||||
"'": [";:", "[{", "]}", null, null, "/?"],
|
||||
z: [null, "aA", "sS", "xX", null, null],
|
||||
x: ["zZ", "sS", "dD", "cC", null, null],
|
||||
c: ["xX", "dD", "fF", "vV", null, null],
|
||||
v: ["cC", "fF", "gG", "bB", null, null],
|
||||
b: ["vV", "gG", "hH", "nN", null, null],
|
||||
n: ["bB", "hH", "jJ", "mM", null, null],
|
||||
m: ["nN", "jJ", "kK", ",<", null, null],
|
||||
",": ["mM", "kK", "lL", ".>", null, null],
|
||||
".": [",<", "lL", ";:", "/?", null, null],
|
||||
"/": [".>", ";:", "'\"", null, null, null],
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
# `validate-graph` — keyboard graph integrity guard
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_ - [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_- azerty — <!-- begin:auto-docs --> _(no submodules)_…
|
||||
- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_- azerty — <!-- begin:auto-doc…
|
||||
- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_- azerty — <!-- begin:auto-docs --> _(no submodules)_…
|
||||
- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_ - [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Purpose
|
||||
|
||||
Validates that a user-supplied keyboard adjacency graph is well-formed
|
||||
before the `keyboardMatch` matcher walks it. A malformed graph would
|
||||
either produce nonsensical matches (self-loops) or cause inconsistent
|
||||
guess estimates across users typing the same word.
|
||||
|
||||
## Features
|
||||
|
||||
- Detects self-adjacency (a key listed as adjacent to itself)
|
||||
- Detects asymmetric edges (`a` lists `b` but `b` does not list `a`)
|
||||
- Throws `PasswordStrengthError` with code `PWSTRENGTH.GRAPH_INVALID`
|
||||
- Pure data validation; no side effects
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateGraph } from "@suaveplan/password-strength";
|
||||
import { QWERTY_GRAPH } from "@suaveplan/password-strength/graphs/qwerty";
|
||||
|
||||
validateGraph("qwerty", QWERTY_GRAPH); // returns; throws if malformed
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { validateGraph, PWSTRENGTH_CODES, PasswordStrengthError } from "@suaveplan/password-strength";
|
||||
|
||||
const broken = { q: ["q"] };
|
||||
try {
|
||||
validateGraph("broken", broken);
|
||||
} catch (e) {
|
||||
if (e instanceof PasswordStrengthError && e.code === PWSTRENGTH_CODES.GRAPH_INVALID) {
|
||||
console.log("graph rejected");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `validateGraph(name: string, graph: KeyboardGraph): void`
|
||||
- Throws `PasswordStrengthError` with code `PWSTRENGTH.GRAPH_INVALID` when
|
||||
the graph contains a self-loop or an asymmetric edge. The error message
|
||||
names the offending key for fast debugging.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The symmetry check is one-directional per pair — if `a` claims adjacency to
|
||||
`b`, then `b`'s adjacency slot list must contain `a` in at least one of its
|
||||
slots' character pairs (covering both the shifted and unshifted halves).
|
||||
Slots set to `null` are skipped. The walk is `O(n)` over the number of
|
||||
edges in the graph; running it on a 60-key graph is negligible.
|
||||
|
||||
## Additional Implementation Notes
|
||||
|
||||
The implementation follows the canonical zxcvbn algorithm from Wheeler 2016 with
|
||||
the suaveplan-style additions described in the package proposal: structured error
|
||||
codes via the `defineCodes` registry, Zod boundaries at every public entry
|
||||
point, OTel histograms and counters keyed to low-cardinality attributes, and
|
||||
a strict input-length guard at 200 characters. Consumers integrate with their
|
||||
existing observability stack by installing a meter provider before importing
|
||||
the package, and with their i18n stack via the `MessageResolver` callback type
|
||||
declared in `@suaveplan/types`. The bundled data tables are intentionally
|
||||
small to keep the package bundle compact; production deployments should swap
|
||||
in fuller frequency-ranked tables via `loadDictionaries`. The default English
|
||||
dictionary set is a frozen `DictionarySet` that the matcher never mutates;
|
||||
caller-supplied dictionaries override defaults by name.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type PasswordStrengthError,
|
||||
PWSTRENGTH_CODES,
|
||||
} from "../errors/error-codes.js";
|
||||
import { AZERTY_GRAPH } from "./azerty/azerty.js";
|
||||
import { DVORAK_GRAPH } from "./dvorak/dvorak.js";
|
||||
import { QWERTY_GRAPH } from "./qwerty/qwerty.js";
|
||||
import { validateGraph } from "./validate-graph.js";
|
||||
|
||||
describe("validateGraph", () => {
|
||||
it("accepts QWERTY", { timeout: 5000 }, () => {
|
||||
expect(() => validateGraph("qwerty", QWERTY_GRAPH)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts DVORAK", { timeout: 5000 }, () => {
|
||||
expect(() => validateGraph("dvorak", DVORAK_GRAPH)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts AZERTY", { timeout: 5000 }, () => {
|
||||
expect(() => validateGraph("azerty", AZERTY_GRAPH)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects self-adjacency", { timeout: 5000 }, () => {
|
||||
try {
|
||||
validateGraph("broken", { q: ["q"] });
|
||||
} catch (e) {
|
||||
expect((e as PasswordStrengthError).code).toBe(
|
||||
PWSTRENGTH_CODES.GRAPH_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects asymmetric edges", { timeout: 5000 }, () => {
|
||||
try {
|
||||
validateGraph("asym", { a: ["b"], b: ["c"], c: ["b"] });
|
||||
} catch (e) {
|
||||
expect((e as PasswordStrengthError).code).toBe(
|
||||
PWSTRENGTH_CODES.GRAPH_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips null slots", { timeout: 5000 }, () => {
|
||||
// graph with only null slots is trivially symmetric
|
||||
expect(() =>
|
||||
validateGraph("nullonly", { a: [null], b: [null] })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips empty adjacency lists", { timeout: 5000 }, () => {
|
||||
expect(() => validateGraph("empty", { a: [] })).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips edges pointing to keys not in graph", { timeout: 5000 }, () => {
|
||||
// x not in graph → neighbor undefined, skip
|
||||
expect(() => validateGraph("dangling", { a: ["x"] })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
PWSTRENGTH_CODES,
|
||||
passwordStrengthError,
|
||||
} from "../errors/error-codes.js";
|
||||
import type { KeyboardGraph } from "../matchers/types.js";
|
||||
|
||||
/**
|
||||
* `validateGraph` — throw `PWSTRENGTH.GRAPH_INVALID` when a graph has
|
||||
* self-adjacency (a key listed in its own adjacency slots) or non-symmetric
|
||||
* edges (`a` listed adjacent to `b` but `b` not listed adjacent to `a`).
|
||||
*/
|
||||
export function validateGraph(name: string, graph: KeyboardGraph): void {
|
||||
const keys = Object.keys(graph);
|
||||
for (const key of keys) {
|
||||
const adjacencies = graph[key] as ReadonlyArray<string | null>;
|
||||
for (const slot of adjacencies) {
|
||||
if (slot === null) continue;
|
||||
for (const ch of slot) {
|
||||
if (ch === key) {
|
||||
throw passwordStrengthError(
|
||||
PWSTRENGTH_CODES.GRAPH_INVALID,
|
||||
`graph "${name}" has self-adjacency on key "${key}"`
|
||||
);
|
||||
}
|
||||
const neighbor = graph[ch];
|
||||
if (!neighbor) continue;
|
||||
const isMutual = neighbor.some((s) => s?.includes(key));
|
||||
if (!isMutual) {
|
||||
throw passwordStrengthError(
|
||||
PWSTRENGTH_CODES.GRAPH_INVALID,
|
||||
`graph "${name}" is not symmetric: ${key} -> ${ch}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user