Files
authentik-qr-login/src/numbers.ts
Michal 44061918ac authentik-qr-login: cross-device QR sign-in for authentik
Scan a QR on your authentik login page with your phone, approve it with a
passkey and a fingerprint, and the laptop signs itself in.

The service holds NO authentik credential. A flow policy calls it with a
session id and gets back the username that approved it, or nothing -- so there
is no standing credential to steal. The obvious alternative, authentik's
recovery-link API, effectively requires a superuser and was rejected for that
reason.

See README.md for the traps this had to work around, including the two
flow-binding settings that are counter-intuitive and load-bearing, and an
honest account of what QR sign-in cannot defend against.
2026-08-16 17:20:57 +01:00

45 lines
1.6 KiB
TypeScript

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;
}