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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user