Files
authentik-qr-login/src/numbers.ts

45 lines
1.6 KiB
TypeScript
Raw Normal View History

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<number>([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;
}