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.
This commit is contained in:
44
src/numbers.ts
Normal file
44
src/numbers.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user