Files
suaveplan/packages/security-stack/security/password-strength/src/matchers/keyboard/keyboard.ts
2026-05-27 23:47:53 +01:00

105 lines
3.0 KiB
TypeScript

import { QWERTY_GRAPH } from "../../graphs/qwerty/qwerty.js";
import { validateGraph } from "../../graphs/validate-graph.js";
import type {
KeyboardGraph,
KeyboardGraphMap,
KeyboardMatch,
} from "../types.js";
export interface KeyboardMatchOptions {
graphs?: KeyboardGraphMap;
}
/**
* `keyboardMatch` — find every substring that traces a walk on a keyboard
* adjacency graph per Wheeler 2016 section 3.5. Each match carries its graph
* name, number of direction changes (`turns`), and number of shift-required
* presses (`shiftedCount`).
*/
export function keyboardMatch(
password: string,
opts: KeyboardMatchOptions = {}
): KeyboardMatch[] {
const graphs: KeyboardGraphMap = opts.graphs ?? { qwerty: QWERTY_GRAPH };
for (const [name, graph] of Object.entries(graphs)) {
validateGraph(name, graph);
}
const out: KeyboardMatch[] = [];
for (const [graphName, graph] of Object.entries(graphs)) {
out.push(...walkGraph(password, graphName, graph));
}
return out;
}
interface SlotHit {
slot: number;
shifted: boolean;
}
function findSlot(
graph: KeyboardGraph,
prev: string,
next: string
): SlotHit | undefined {
const adjacencies = graph[prev];
if (!adjacencies) return undefined;
for (let s = 0; s < adjacencies.length; s++) {
const slot = adjacencies[s];
if (slot === null || slot === undefined) continue;
const unshifted = slot[0];
const shifted = slot[1];
if (next === unshifted) return { slot: s, shifted: false };
if (next === shifted) return { slot: s, shifted: true };
}
return undefined;
}
function isShifted(graph: KeyboardGraph, ch: string): boolean {
if (graph[ch]) return false;
for (const adjs of Object.values(graph)) {
for (const slot of adjs) {
if (slot && slot[1] === ch) return true;
}
}
return false;
}
function walkGraph(
password: string,
graphName: string,
graph: KeyboardGraph
): KeyboardMatch[] {
const out: KeyboardMatch[] = [];
if (password.length < 2) return out;
let i = 0;
while (i < password.length - 1) {
let j = i;
let lastSlot = -1;
let turns = 0;
let shiftedCount = isShifted(graph, password[i] as string) ? 1 : 0;
while (j < password.length - 1) {
const cur = (password[j] as string).toLowerCase();
const next = password[j + 1] as string;
const hit = findSlot(graph, cur, next);
if (!hit) break;
if (lastSlot !== -1 && hit.slot !== lastSlot) turns++;
lastSlot = hit.slot;
if (hit.shifted) shiftedCount++;
j++;
}
if (j - i + 1 >= 2) {
out.push({
pattern: "spatial",
i,
j,
token: password.slice(i, j + 1),
graph: graphName,
turns,
shiftedCount,
});
}
i = j + 1;
}
return out;
}