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>
107 lines
5.1 KiB
TypeScript
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);
|
|
});
|