Files
craftvia/scripts/test-mfa-hardening.ts
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

107 lines
5.1 KiB
TypeScript

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