// Korrektheitsnachweis für F-17: MFA-Härtung (Krypto + TOTP-Replay-Schutz). // // Drei Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0): // (1) Recovery-Codes: ein generierter Code verifiziert korrekt gegen seinen // Argon2id-Hash (matchRecovery findet ihn), ein falscher Code nicht. // Zusatz: der Kompatibilitätspfad akzeptiert einen Bestands-Hash im // SHA-256-Format weiterhin. // (2) Entropie/Format: 4er-Gruppierung, 20 Hex-Zeichen normalisiert = 80 Bit // Entropie (>= 64 Bit gefordert), Hashes sind Argon2id ($argon2id$). // (3) TOTP-Replay: derselbe Code/Zeitschritt wird beim zweiten Mal (mit // afterStep = lastTotpStep) abgelehnt; ein älterer Zeitschritt lässt den // aktuellen Code weiterhin zu. // // Lauf: npx tsx scripts/test-mfa-hardening.ts (reine Krypto-/TOTP-Logik, keine DB). import "dotenv/config"; import { createHash } from "node:crypto"; import { generateSync } from "otplib"; import { generateRecoveryCodes, matchRecovery, hashRecovery, verifyTotp, newTotpSecret, } from "@/server/mfa"; function assert(cond: unknown, msg: string): asserts cond { if (!cond) { console.error(`FEHLGESCHLAGEN: ${msg}`); process.exit(1); } console.log(` ok: ${msg}`); } async function main() { // ── (1) Recovery-Codes: Argon2id-Verifikation korrekt/ falsch ──────────── console.log("\n(1) Recovery-Codes gegen Argon2id-Hashes:"); const { plain, hashed } = await generateRecoveryCodes(10); assert(plain.length === 10 && hashed.length === 10, "10 Codes + 10 Hashes erzeugt"); assert( hashed.every((h) => h.startsWith("$argon2id$")), "alle Hashes sind Argon2id ($argon2id$)", ); // Ein echter Code (mit und ohne Gruppierungs-Bindestriche) wird gefunden. const idx = await matchRecovery(plain[3], hashed); assert(idx === 3, "korrekter Recovery-Code wird am richtigen Index gefunden"); const idxNormalized = await matchRecovery(plain[3].replace(/-/g, "").toUpperCase(), hashed); assert(idxNormalized === 3, "Code wird normalisiert erkannt (ohne Bindestriche, Großschreibung)"); // Ein falscher Code wird abgelehnt. const idxWrong = await matchRecovery("0000-0000-0000-0000", hashed); assert(idxWrong === -1, "falscher Recovery-Code wird abgelehnt (-1)"); // Kompatibilitätspfad: ein Bestands-Hash im SHA-256-Format wird weiter akzeptiert. const legacyPlain = "abcd-1234-ef56"; const legacySha = createHash("sha256").update(legacyPlain.replace(/[\s-]/g, "").toLowerCase()).digest("hex"); const legacyIdx = await matchRecovery(legacyPlain, [legacySha]); assert(legacyIdx === 0, "Bestands-Hash (SHA-256, 64 Hex) wird übergangsweise akzeptiert"); const legacyWrong = await matchRecovery("ffff-ffff-ffff", [legacySha]); assert(legacyWrong === -1, "falscher Code gegen SHA-256-Bestandshash wird abgelehnt"); // ── (2) Entropie / Format ──────────────────────────────────────────────── console.log("\n(2) Entropie und Format:"); const groupFormat = /^[0-9a-f]{5}-[0-9a-f]{5}-[0-9a-f]{5}-[0-9a-f]{5}$/; assert(plain.every((c) => groupFormat.test(c)), "Format 5-5-5-5 Hex mit Gruppierung"); const normalizedLen = plain[0].replace(/-/g, "").length; assert(normalizedLen === 20, "20 Hex-Zeichen normalisiert (= 80 Bit Entropie, >= 64 gefordert)"); const bits = normalizedLen * 4; assert(bits >= 64, `Entropie ${bits} Bit >= 64 Bit`); // hashRecovery liefert einen frischen Argon2id-Hash (Salt → zwei Hashes verschieden). const h1 = await hashRecovery(plain[0]); const h2 = await hashRecovery(plain[0]); assert(h1.startsWith("$argon2id$") && h2.startsWith("$argon2id$"), "hashRecovery liefert Argon2id"); assert(h1 !== h2, "gesalzen: zwei Hashes desselben Codes unterscheiden sich"); // ── (3) TOTP-Replay-Schutz ──────────────────────────────────────────────── console.log("\n(3) TOTP-Replay-Schutz:"); const secret = newTotpSecret(); const token = generateSync({ secret }); // Code für den aktuellen Zeitschritt const first = verifyTotp(token, secret); assert(first.ok, "TOTP-Code verifiziert im aktuellen Zeitfenster"); const step = first.ok ? first.step : -1; console.log(` (akzeptierter Zeitschritt: ${step})`); // Zweite Verwendung mit lastTotpStep = step → Replay wird abgelehnt. const replay = verifyTotp(token, secret, step); assert(!replay.ok, "derselbe Code/Zeitschritt wird beim zweiten Mal abgelehnt (Replay)"); // Ein älterer lastTotpStep (step - 1) lehnt den aktuellen Code NICHT ab. const older = verifyTotp(token, secret, step - 1); assert(older.ok && (older.ok ? older.step : -1) === step, "älterer Zeitschritt lässt den aktuellen Code zu"); // Ein offensichtlich falscher Code ist immer ungültig. const bad = verifyTotp("000000", secret); assert(!bad.ok, "falscher 6-stelliger Code wird abgelehnt"); console.log("\nOK — alle F-17-Nachweise bestanden."); } main().catch((e) => { console.error(e); process.exit(1); });