From 2b0af772a2730a8a33ce760272065b0827a567c0 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 19 Aug 2026 21:42:48 +0200 Subject: [PATCH] Manifest-Kanal abgesichert (Pentest R171-01 HIGH, R171-03 LOW) verifyIntegrity vertraute Loeschungs-Manifesten bedingungslos, ohne zu pruefen, ob die Traegerzeile signiert und gueltig ist. Das Manifest steht in changesAfter, und dieses Feld ist erst ab Hash-Version 2 mitgehasht - auf V1-Altzeilen also voellig unauthentifiziert. Ein Angreifer konnte in eine beliebige V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash zu aendern, und damit eigene Loeschungen als "erklaert" ausweisen. Damit fiel zugleich die Eskalation an signierten Zeilen aus - Anker UND Versionsgrenze umgangen. Fix: Ein Manifest zaehlt nur, wenn die Traegerzeile laut Versionsgrenze Stufe 3 sein muss, dies auch deklariert, und ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. Ohne Schluessel gibt es keine gueltigen Traeger - Luecken bleiben dann unerklaert, die sichere Richtung. R171-03: Eskalierte Luecken standen in tamperedEntries und chainGaps, invalidEntries zaehlte sie doppelt. Jetzt entdoppelt. Verifiziert: boeswillige Loeschung -> Befund; erfundenes Manifest in herabgestufter Traegerzeile -> ignoriert, Luecke bleibt Befund, Traegerzeile selbst beanstandet; legitimes signiertes Manifest erklaert die Luecke weiterhin. tsc gruen. Co-Authored-By: Claude Opus 5 --- backend/src/services/audit.service.ts | 38 +++++++++++++++++++++------ docs/todo.md | 27 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index a801b21e..ab3b7696 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -720,16 +720,36 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ return 1; }; - // Dokumentierte Loeschungen einlesen: Luecken, die auf einen protokollierten - // Retention-Cleanup zurueckgehen, sind erklaert – alle anderen nicht. Vorher - // war das Manifest rein informativ und wurde von der Pruefung ignoriert, - // wodurch sich eine boeswillige Loeschung als "harmloser Gap" tarnen konnte. + // Dokumentierte Loeschungen einlesen – NUR aus signierten, selbst gueltigen + // Traegerzeilen (Pentest R171-01). + // + // Vorher wurde jedem Manifest bedingungslos geglaubt. Das Manifest steht in + // `changesAfter`, und dieses Feld ist erst ab Hash-Version 2 mitgehasht – auf + // V1-Altzeilen also voellig unauthentifiziert. Ein Angreifer konnte daher in + // eine BELIEBIGE V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash + // zu veraendern, und damit eigene Loeschungen und Luecken als "erklaert" + // ausweisen. Das hebelte zugleich die Eskalation an signierten Zeilen aus – + // also Anker UND Versionsgrenze. + // + // Deshalb zaehlt ein Manifest nur, wenn seine Traegerzeile + // 1. laut Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert, und + // 2. ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. + // Ohne Schluessel gibt es keine gueltigen Traeger – dann bleiben Luecken + // unerklaert, was die sichere Richtung ist. const deletionRanges: Array<{ from: number; to: number }> = []; - const manifestRows = await prisma.auditLog.findMany({ + const manifestKandidaten = await prisma.auditLog.findMany({ where: { resourceType: 'AuditLog', action: 'DELETE', endpoint: '/api/audit-logs/cleanup' }, - select: { changesAfter: true }, }); - for (const row of manifestRows) { + const manifestSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter( + (k): k is string => !!k, + ); + for (const row of manifestKandidaten) { + if (erwarteteVersion(row.id) !== 3 || row.hashVersion < 3) continue; + if (manifestSchluessel.length === 0) continue; + const signaturOk = manifestSchluessel.some( + (k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k), + ); + if (!signaturOk) continue; try { const parsed = JSON.parse(row.changesAfter || '{}'); for (const m of parsed.manifest || []) { @@ -853,7 +873,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ } } - const invalidEntries = [...tamperedEntries, ...chainGaps].sort((a, b) => a - b); + // Eskalierte Luecken stehen sowohl in tamperedEntries als auch in chainGaps; + // ohne Entdopplung zaehlte dieselbe Zeile zweimal (Pentest R171-03). + const invalidEntries = [...new Set([...tamperedEntries, ...chainGaps])].sort((a, b) => a - b); return { valid: invalidEntries.length === 0, diff --git a/docs/todo.md b/docs/todo.md index 362bc617..b0875246 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,33 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18) + - **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten + bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert + und gueltig ist. Das Manifest steht in `changesAfter`, und dieses Feld ist + erst ab Hash-Version 2 mitgehasht, auf **V1-Altzeilen also voellig + unauthentifiziert**. Ein Angreifer konnte in eine BELIEBIGE V1-Zeile ein + erfundenes Manifest schreiben, ohne deren Hash zu veraendern, und damit + eigene Loeschungen als „erklaert“ ausweisen – womit zugleich die + Eskalation an signierten Zeilen ausfiel. Damit waren **Anker UND + Versionsgrenze** umgangen. Der Pentester hat es live gezeigt; es war genau + die Stelle, die ich beim Uebergeben als meine schwaechste benannt hatte. + - Fix: Ein Manifest zaehlt nur noch, wenn seine Traegerzeile (1) laut + Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert und (2) ihre + HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. Ohne Schluessel + gibt es keine gueltigen Traeger – dann bleiben Luecken unerklaert, was die + sichere Richtung ist. + - **R171-03:** Eskalierte Luecken standen in `tamperedEntries` UND + `chainGaps`; `invalidEntries` zaehlte sie doppelt. Jetzt entdoppelt. + - Verifiziert: boeswillige Loeschung → Befund; erfundenes Manifest in + herabgestufter Traegerzeile → **ignoriert**, Luecke bleibt Befund und die + Traegerzeile wird selbst beanstandet; **legitimes signiertes Manifest + erklaert die Luecke weiterhin** (Funktion nicht kaputt). `tsc` gruen. + - Hinweis: Auf der Dev-DB existiert kein V1-Bestand mehr (der Rehash-Test aus + R170-01 hat alles auf V3 gehoben), der Angriff wurde daher mit einer gezielt + herabgestuften Traegerzeile nachgestellt. Auf Staging/Prod ist der + V1-Altbestand real vorhanden. + - [x] **🧾 rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)** (2026-08-18) - Fund: `POST /audit-logs/rehash` (audit:admin) rechnet die Kette MIT dem HMAC-Schluessel neu und macht sie damit wieder stimmig – auch wenn sie