diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index c5cb7aaa..11f71dc6 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -273,6 +273,49 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { 'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.' : ''; + // Eine Neuberechnung der Kette gehoert IMMER erwaehnt – auch und gerade, + // wenn sonst alles grün ist. Nach einem Rehash ist die Kette + // zwangslaeufig stimmig, auch ueber Loeschungen hinweg. „Lueckenlos + // verkettet“ heisst dann nur noch „seit dem Rehash“, und wer das nicht + // mitliest, nimmt eine Entwarnung mit, die es so nicht gibt. + const rehashText = (() => { + if (result.rehashes.length === 0) return ''; + const letzter = result.rehashes[result.rehashes.length - 1]; + const datum = new Date(letzter.zeitpunkt).toLocaleString('de-DE', { + dateStyle: 'short', + timeStyle: 'short', + }); + const wieOft = + result.rehashes.length === 1 + ? 'Die Kette wurde einmal neu berechnet' + : `Die Kette wurde ${result.rehashes.length}× neu berechnet, zuletzt`; + // Nur benennen, was es zu benennen gibt. Bei sauberem Vorzustand ist die + // Auskunft „nichts uebertuencht“ selbst eine nuetzliche Information – + // ein Rehash ueber einer unbeanstandeten Kette wiegt anders als einer + // ueber 700 Luecken. + const vb = letzter.vorbefund; + const uebertuencht = !vb + ? ' Der Zustand vor dieser Neuberechnung ist nicht mehr feststellbar.' + : vb.manipuliert === 0 && vb.luecken === 0 + ? ' Die Kette war unmittelbar davor unbeanstandet – es wurde nichts überdeckt.' + : ' Unmittelbar davor: ' + + `${vb.manipuliert} beanstandete${vb.manipuliert === 1 ? 'r Eintrag' : ' Einträge'}` + + ` und ${vb.luecken} Lücke${vb.luecken === 1 ? '' : 'n'}. ` + + 'Diese Spuren sind seitdem nicht mehr in der Kette sichtbar, sondern nur noch im ' + + `Vorbefund des Rehash-Eintrags (id ${letzter.id}).`; + const ohneSignatur = result.rehashes.some((r) => !r.signiert) + ? ' Achtung: Mindestens ein Rehash-Eintrag trägt keine gültige Signatur.' + : ''; + return ( + ` ⚠ ${wieOft} am ${datum}` + + (letzter.neuBerechnet !== null ? ` (${letzter.neuBerechnet} Einträge)` : '') + + '. Ein Rehash verknüpft alle Einträge neu – die Aussage dieser Prüfung ' + + 'reicht deshalb nur bis dorthin zurück, nicht weiter.' + + uebertuencht + + ohneSignatur + ); + })(); + // Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es // ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03). const mehrfach = @@ -303,7 +346,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { : beglaubigt > 0 ? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' + 'entstanden.' - : 'Alle Einträge sind unverändert und lückenlos verkettet.'; + : result.rehashes.length > 0 + ? 'Alle Einträge sind unverändert und lückenlos verkettet – allerdings erst ' + + 'seit der letzten Neuberechnung.' + : 'Alle Einträge sind unverändert und lückenlos verkettet.'; res.json({ success: true, @@ -319,8 +365,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { unexplainedGaps: result.unexplainedGaps, // Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt. attestedGaps: result.attestedGaps, + // Protokollierte Neuberechnungen – begrenzen die Reichweite der Aussage. + rehashes: result.rehashes, tampered: tampered > 0, - message: message + beglaubigtText + keinSchluessel + siegel + mehrfach, + message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach, unverifiableEntries: result.unverifiableEntries, // Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. backlogSealStatus: result.backlogSealStatus, diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index 1e6e6369..886f29f3 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -909,6 +909,24 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ * nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten). */ attestedGaps: number[]; + /** + * Protokollierte Neuberechnungen der Kette (Rehash), aelteste zuerst. + * + * Ein Rehash verknuepft alle Eintraege neu. Danach ist die Kette + * zwangslaeufig stimmig - auch ueber Loeschungen hinweg, die vorher als + * Luecken sichtbar waren. „Lueckenlos verkettet“ heisst nach einem Rehash + * also nur noch: seit dem Rehash. Wer das nicht mitliest, nimmt eine + * Entwarnung mit, die es nicht gibt. + */ + rehashes: Array<{ + id: number; + zeitpunkt: string; + neuBerechnet: number | null; + /** Marker mit gueltiger HMAC-Signatur? Siehe Kommentar an der Auswertung. */ + signiert: boolean; + /** Befund unmittelbar VOR dem Rehash - was also uebertuencht wurde. */ + vorbefund: { manipuliert: number; luecken: number } | null; + }>; /** * Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. * `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein @@ -1044,6 +1062,68 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ const gapErklaert = (prevId: number, curId: number) => deletionRanges.some((r) => r.from <= curId && r.to >= prevId); + // --------------------------------------------------------------------- + // Protokollierte Neuberechnungen (Rehash) einsammeln. + // + // Ein Rehash macht die Kette rechnerisch stimmig - auch dort, wo vorher + // Loeschungen als Luecken sichtbar waren. Genau das ist auf Staging passiert: + // zwei Cleanups mit Aufbewahrung 0 entfernten 3155 Eintraege, der folgende + // Rehash liess rund 700 Kettenluecken verschwinden, und die Pruefung meldete + // danach „unveraendert und lueckenlos verkettet“. Wahr - und trotzdem das + // Gegenteil dessen, was ein Leser mitnimmt. + // + // Umgekehrte Beweislast als bei Manifest und Siegel: Dort zaehlen NUR + // signierte Traeger, weil ein gefaelschter Marker Luecken wegerklaeren + // koennte - Misstrauen ist die sichere Richtung. Hier erzeugt ein Marker eine + // WARNUNG. Wuerden wir nur signierte gelten lassen, koennte jemand einen + // Rehash unsichtbar machen, indem er dessen Signatur zerstoert. Deshalb + // zaehlt hier jeder auswertbare Marker; ob er signiert ist, wird nur + // mitgeteilt. + const rehashKandidaten = await prisma.auditLog.findMany({ + where: { resourceType: 'AuditLog', action: 'UPDATE', endpoint: '/api/audit-logs/rehash' }, + orderBy: { id: 'asc' }, + }); + const rehashSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter( + (k): k is string => !!k, + ); + const rehashes: Array<{ + id: number; + zeitpunkt: string; + neuBerechnet: number | null; + signiert: boolean; + vorbefund: { manipuliert: number; luecken: number } | null; + }> = []; + for (const row of rehashKandidaten) { + let neuBerechnet: number | null = null; + let vorbefund: { manipuliert: number; luecken: number } | null = null; + try { + const nach = JSON.parse(row.changesAfter || '{}'); + if (typeof nach.neuBerechnet !== 'number') continue; // kein Rehash-Marker + neuBerechnet = nach.neuBerechnet; + const vor = JSON.parse(row.changesBefore || '{}'); + if (Array.isArray(vor.manipuliert) && Array.isArray(vor.ketten_luecken)) { + vorbefund = { + manipuliert: vor.manipuliert.length, + luecken: vor.ketten_luecken.length, + }; + } + } catch { + continue; + } + const signiert = + row.hashVersion >= 3 && + rehashSchluessel.some( + (k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k), + ); + rehashes.push({ + id: row.id, + zeitpunkt: row.createdAt.toISOString(), + neuBerechnet, + signiert, + vorbefund, + }); + } + const tamperedEntries: number[] = []; const chainGaps: number[] = []; const unexplainedGaps: number[] = []; @@ -1349,6 +1429,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ unexplainedGaps, unverifiableEntries, attestedGaps, + rehashes, backlogSealStatus, backlogTampered, backlogMissing, diff --git a/docs/todo.md b/docs/todo.md index 50a6f87c..9592d098 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,42 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26) + - **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete + „Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem + Protokoll: am 22.08. wurden die Aufbewahrungsfristen auf **0** gesetzt, + zwei Cleanups löschten **3.155** Einträge (id 1–5356), danach lief ein + **Rehash**. Seither meldet die Prüfung „Alle Einträge sind unverändert und + **lückenlos** verkettet". + - Wahr – und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der + Rehash verknüpft alles neu; die rund **700** Kettenlücken, die davor + bestanden, sind seitdem unsichtbar. Nachweisbar im Vorbefund, den der + Rehash selbst mitschreibt (R170-01) – nur schaute den nie jemand an. + - Das Gegenbuch war der einzige Zeuge. Innerhalb des CRM war die Löschung + nicht mehr feststellbar. + - `verifyIntegrity` sammelt jetzt die Rehash-Marker; die Antwort enthält + `rehashes[]` (Zeitpunkt, Anzahl, Signatur, Vorbefund). Die Meldung nennt + sie **immer**, auch im grünen Fall, und der Einstiegssatz lautet dann + „…lückenlos verkettet – allerdings erst seit der letzten Neuberechnung". + - **`valid` bleibt unberührt.** Ein Rehash ist eine legitime Maßnahme; ihn + dauerhaft als Befund zu führen wäre der Dauer-Alarm, den wir mit den + beglaubigten Lücken gerade beseitigt haben. Melden, nicht alarmieren. + - **Umgekehrte Beweislast als bei Manifest und Siegel:** Dort zählen nur + signierte Träger, weil ein gefälschter Marker Lücken *wegerklären* könnte. + Hier erzeugt ein Marker eine *Warnung* – würden nur signierte zählen, + könnte man einen Rehash unsichtbar machen, indem man seine Signatur + zerstört. Deshalb zählt jeder auswertbare Marker; fehlende Signatur wird + zusätzlich gemeldet. + - Getestet über HTTP gegen eine Wegwerf-DB, mit echtem Rehash über den + regulären Endpunkt: sauberer Vorzustand → „es wurde nichts überdeckt"; + Löschung + Rehash (der Staging-Ablauf im Kleinen) → `chainGaps` fällt von + 1 auf 0, die Meldung nennt Zeitpunkt und Vorbefund; zerstörte Signatur → + Marker wird weiterhin gemeldet, mit Hinweis. + - Dateien: `backend/src/services/audit.service.ts`, + `backend/src/controllers/auditLog.controller.ts`, + `frontend/src/services/api.ts`, + `frontend/src/pages/settings/AuditIntegrityCard.tsx` + - [x] **📄 `status.txt` nennt jetzt den Grund, nicht nur das Etikett** (2026-08-26) - In der Statusdatei des Gegenbuchs stand für **jeden** Exit-2 derselbe Satz: „BEFUND – Widerspruch zwischen CRM und Gegenbuch". Ein echter Widerspruch diff --git a/frontend/src/pages/settings/AuditIntegrityCard.tsx b/frontend/src/pages/settings/AuditIntegrityCard.tsx index f4863c07..d9f28d40 100644 --- a/frontend/src/pages/settings/AuditIntegrityCard.tsx +++ b/frontend/src/pages/settings/AuditIntegrityCard.tsx @@ -126,7 +126,12 @@ export default function AuditIntegrityCard() {
-

{AMPEL_TITEL[ampel]}

+

+ {AMPEL_TITEL[ampel]} + {ampel === 'ok' && ergebnis.rehashes.length > 0 && ( + – seit der letzten Neuberechnung + )} +

{ergebnis.message}

{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft · @@ -156,9 +161,36 @@ export default function AuditIntegrityCard() { {ergebnis.tamperedEntries.length === 0 && ergebnis.chainGaps.length === 0 && - ergebnis.unverifiableEntries.length === 0 && ( + ergebnis.unverifiableEntries.length === 0 && + ergebnis.rehashes.length === 0 && (

Nichts zu berichten.

)} + {ergebnis.rehashes.length > 0 && ( +
+

Neuberechnungen der Kette

+ {ergebnis.rehashes.map((r) => ( +

+ {new Date(r.zeitpunkt).toLocaleString('de-DE')} · Eintrag {r.id} + {r.neuBerechnet !== null && ` · ${r.neuBerechnet} Einträge neu verkettet`} + {r.vorbefund && ( + + {' '}· davor: {r.vorbefund.manipuliert} beanstandet,{' '} + {r.vorbefund.luecken} Lücken + + )} + {!r.signiert && ( + · ohne gültige Signatur + )} +

+ ))} +

+ Ein Rehash verknüpft alle Einträge neu. Was davor als Lücke oder + Beanstandung sichtbar war, ist danach nicht mehr in der Kette zu sehen – + nur noch im Vorbefund des jeweiligen Eintrags. Die Aussage dieser Prüfung + reicht deshalb bis zur letzten Neuberechnung zurück, nicht weiter. +

+
+ )}

Verändert heißt: der Inhalt einer bestehenden Zeile passt nicht mehr zu ihrer Prüfsumme – das ist ernst. diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 327e573a..a061ce92 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1749,6 +1749,18 @@ export interface IntegrityResult { unexplainedGaps: number[]; /** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */ attestedGaps: number[]; + /** + * Protokollierte Neuberechnungen der Kette. Nach einem Rehash ist die Kette + * zwangslaeufig stimmig - auch ueber Loeschungen hinweg. Die Aussage der + * Pruefung reicht dann nur bis zur letzten Neuberechnung zurueck. + */ + rehashes: Array<{ + id: number; + zeitpunkt: string; + neuBerechnet: number | null; + signiert: boolean; + vorbefund: { manipuliert: number; luecken: number } | null; + }>; /** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */ unverifiableEntries: number[]; backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';