import { randomInt } from "node:crypto"; /** * The number-matching challenge. * * The laptop shows ONE number; the phone asks for it. This defeats blind * approval — an attacker who can trigger a prompt but cannot see the victim's * screen has to guess. * * How much it buys is arithmetic, and worth stating because "number matching" * sounds stronger than the weak variant is: * * mode "type" two digits, entered on a keypad log2(90) = 6.5 bits * mode "choice" one of three buttons log2(3) = 1.6 bits * * With a single attempt and a cap of 3 sessions per source, "choice" leaves an * attacker at 1 - (2/3)^3 = 70%, which is not a control. "type" leaves them at * 1 - (89/90)^3 = 3.3%. Hence `type` is the default; `choice` exists because it * is the friendlier UX and some deployments will want it with eyes open. */ export type NumberMode = "type" | "choice"; /** Two digits. Never 0-9: a leading zero reads ambiguously across fonts. */ export function pickNumber(): number { return randomInt(10, 100); } /** * Two decoys for `choice` mode, distinct from each other and from the answer. * Returned already shuffled, so the correct one is not positionally biased. */ export function pickDecoys(answer: number): number[] { const chosen = new Set([answer]); while (chosen.size < 3) chosen.add(pickNumber()); const all = [...chosen]; // Fisher-Yates with a CSPRNG. Math.random would be fine for display order, // but using it here invites someone to reuse it where it is not. for (let i = all.length - 1; i > 0; i--) { const j = randomInt(0, i + 1); [all[i], all[j]] = [all[j], all[i]]; } return all; }