diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index 27c87e1a..65bfe5b9 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -149,16 +149,38 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { const tampered = result.tamperedEntries.length; const gaps = result.chainGaps.length; const unexplained = result.unexplainedGaps.length; + // Beglaubigte Alt-Luecken sind kein offener Befund mehr, verschwinden aber + // auch nicht aus dem Bericht - sie werden eigens benannt. + const beglaubigt = result.attestedGaps.length; + const offeneGaps = gaps - beglaubigt; + const offeneUnexplained = result.unexplainedGaps.filter( + (id) => !result.attestedGaps.includes(id), + ).length; - const luecken = gaps > 0 - ? `${gaps} strukturelle Lücken` + - (unexplained === 0 + const luecken = offeneGaps > 0 + ? `${offeneGaps} strukturelle Lücke${offeneGaps === 1 ? '' : 'n'}` + + (offeneUnexplained === 0 ? ' (alle durch protokollierte Löschungen erklärt)' - : unexplained < gaps - ? `, davon ${unexplained} ohne dokumentierte Löschung` + : offeneUnexplained < offeneGaps + ? `, davon ${offeneUnexplained} ohne dokumentierte Löschung` : ' ohne dokumentierte Löschung') : ''; + // Der Satz erscheint IMMER, wenn beglaubigte Luecken existieren - auch + // neben einem Befund. Wer den Bericht liest, soll nie den Eindruck + // bekommen, die Kette sei lueckenlos, wenn sie es nicht ist. + const weitere = tampered > 0 || offeneGaps > 0 ? 'weitere ' : ''; + const beglaubigtText = + beglaubigt === 0 + ? '' + : beglaubigt === 1 + ? ` Eine ${weitere}Lücke stammt aus der Zeit vor dem Bestandssiegel und ist darin als ` + + `Vorbefund beglaubigt (ID ${result.attestedGaps[0]}); der betroffene Eintrag selbst ` + + 'ist unverändert.' + : ` ${beglaubigt} ${weitere}Lücken stammen aus der Zeit vor dem Bestandssiegel und sind ` + + `darin als Vorbefund beglaubigt (IDs ${result.attestedGaps.join(', ')}); ` + + 'die betroffenen Einträge selbst sind unverändert.'; + const unverifiable = result.unverifiableEntries.length; const keinSchluessel = unverifiable > 0 ? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.` @@ -207,14 +229,17 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { // stand nur noch im Feld `valid`. Wer die Prosa liest statt des Felds, // klickt genau das weg, was ihn haette warnen sollen. const message = tampered > 0 - ? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '') + ? `${tampered} MANIPULIERTE Einträge gefunden` + (offeneGaps > 0 ? ` (zusätzlich ${luecken})` : '') : siegelProblem ? 'Die Kette selbst ist rechnerisch stimmig, ABER:' - : gaps > 0 + : offeneGaps > 0 ? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` + 'unverändert – aber gelöschte Einträge lassen sich naturgemäß nicht mehr prüfen. ' + 'Dokumentierte Löschungen sind erwartbar; unerwartete gehören nachgegangen.' - : 'Alle Einträge sind unverändert und lückenlos verkettet'; + : 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.'; res.json({ success: true, @@ -228,8 +253,10 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { chainGaps: result.chainGaps, // Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig. unexplainedGaps: result.unexplainedGaps, + // Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt. + attestedGaps: result.attestedGaps, tampered: tampered > 0, - message: message + keinSchluessel + siegel + mehrfach, + message: message + beglaubigtText + 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 15d4c4c2..b0bbca15 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -873,7 +873,11 @@ export async function sealBacklog( export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ valid: boolean; checkedCount: number; - /** Alle beanstandeten Zeilen (tampered + chainGaps) – Abwaertskompatibilitaet. */ + /** + * Alle noch offenen Beanstandungen: veraenderte Zeilen plus Luecken, die + * NICHT vom Bestandssiegel beglaubigt sind. Nur an dieser Liste haengt + * `valid` – beglaubigte Alt-Luecken stehen in `attestedGaps`. + */ invalidEntries: number[]; /** * ERNST: Der Inhalt der Zeile passt nicht mehr zu ihrem Hash – jemand hat @@ -898,6 +902,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ * eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log. */ unverifiableEntries: number[]; + /** + * Teilmenge von `chainGaps`, die beim Versiegeln des Altbestands bereits + * bestand und im signierten Siegel-Marker als Vorbefund festgehalten ist. + * Diese Luecken sind BEGLAUBIGT: sie bleiben sichtbar, kippen `valid` aber + * nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten). + */ + attestedGaps: number[]; /** * Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. * `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein @@ -1048,6 +1059,8 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ // ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01). const backlogTampered: number[] = []; const backlogMissing: number[] = []; + // Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten). + const beglaubigteLuecken = new Set(); let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel'; let backlogSealRoot: string | null = null; @@ -1092,14 +1105,24 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ // Deshalb gilt jetzt: Blaetter vorhanden, aber kein gueltiger Marker = // Siegel ENTFERNT und damit ein Befund – nicht „nie versiegelt“. const blattAnzahl = await prisma.auditBacklogSeal.count(); + const unsignierteAnzahl = await prisma.auditLog.count({ where: { hashVersion: { lt: 3 } } }); if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) { backlogSealStatus = 'entfernt'; - } else if (!siegel && blattAnzahl === 0 && (v3FromId === null || v3FromId <= 1)) { + } else if (!siegel && blattAnzahl === 0 && unsignierteAnzahl === 0) { // Es gibt gar keinen Altbestand: Entweder ist alles signiert, oder das Log // beginnt erst mit der Signierung. Dann ist „nicht versiegelt“ kein Mangel // (Pentest R183-03) – die bisherige Warnung liess sich nicht aufloesen, // weil seal-backlog zu Recht ablehnte. Eine Warnung, die der Betreiber // nicht beheben kann, lernt er zu ignorieren. + // + // Die Bedingung haengt bewusst an der Zahl der UNSIGNIERTEN Zeilen und + // nicht mehr an `v3FromId === null`. Letzteres bedeutet naemlich das + // genaue Gegenteil: dass ueberhaupt nichts signiert ist – etwa weil + // AUDIT_HMAC_KEY fehlt. Ein vollstaendig unsigniertes Log meldete damit + // „Bestandssiegel nicht noetig“, also Entwarnung fuer genau den Zustand + // mit der geringsten Beweiskraft. Jetzt bleibt es bei „kein_siegel“; die + // Fehlermeldung von seal-backlog nennt den fehlenden Schluessel als + // naechsten Schritt, die Warnung ist also aufloesbar. backlogSealStatus = 'nicht_noetig'; } @@ -1140,6 +1163,43 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root ? 'intakt' : 'gebrochen'; + + // --------------------------------------------------------------- + // Beglaubigte Alt-Luecken + // + // Die Race-Luecken aus der Zeit vor dem Sperr-Fix lassen sich nicht + // mehr heilen: die Verkettung ist gebrochen, die Inhalte sind aber + // unversehrt. Wuerden sie `valid` dauerhaft auf false halten, meldete + // das Gegenbuch stuendlich Alarm, ohne dass es je etwas zu tun gaebe - + // und genau daran stirbt jede Warnung. Ein Signal, das immer schreit, + // warnt nicht mehr. + // + // Deshalb gilt eine Luecke als BEGLAUBIGT, wenn beides zutrifft: + // 1. sie liegt im versiegelten Bereich (id <= toId), und + // 2. sie steht im Vorbefund des Siegel-Markers, also im Zustand, den + // der Betreiber beim Siegeln ausdruecklich festgeschrieben hat. + // Der Vorbefund liegt in `changesBefore` und ist ab Version 3 mit- + // gehasht - die Liste laesst sich also ohne Schluessel nicht nachtraeg- + // lich erweitern (gleiche Absicherung wie beim Manifest, R171-01). + // + // Beglaubigt heisst NICHT verschwunden: die Luecken bleiben in + // `chainGaps` und werden weiter berichtet. Sie zaehlen nur nicht mehr + // als offener Befund. Alles andere schlaegt unveraendert an - eine + // NEUE Luecke, eine veraenderte oder entfernte Altzeile, ein gebroche- + // nes Siegel. Bei nicht intaktem Siegel wird gar nichts beglaubigt. + if (backlogSealStatus === 'intakt') { + try { + const roh = siegel.changesEncrypted + ? decrypt(siegel.changesBefore || '') + : siegel.changesBefore || '{}'; + const vorbefund = JSON.parse(roh); + for (const id of vorbefund?.befund?.ketten_luecken || []) { + if (typeof id === 'number' && id <= bis) beglaubigteLuecken.add(id); + } + } catch { + // Unlesbarer Vorbefund beglaubigt nichts - die sichere Richtung. + } + } } catch { backlogSealStatus = 'gebrochen'; } @@ -1252,7 +1312,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ // 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); + // + // Beglaubigte Alt-Luecken zaehlen NICHT als offener Befund (siehe die + // Begruendung an `beglaubigteLuecken`). Sie bleiben in `chainGaps` + // sichtbar und stehen zusaetzlich in `attestedGaps`. + const attestedGaps = chainGaps.filter((id) => beglaubigteLuecken.has(id)); + const offeneLuecken = chainGaps.filter((id) => !beglaubigteLuecken.has(id)); + const invalidEntries = [...new Set([...tamperedEntries, ...offeneLuecken])].sort((a, b) => a - b); // Ein gebrochenes oder entferntes Siegel muss `valid` kippen, auch wenn keine // einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust @@ -1270,6 +1336,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ chainGaps, unexplainedGaps, unverifiableEntries, + attestedGaps, backlogSealStatus, backlogTampered, backlogMissing, diff --git a/docs/todo.md b/docs/todo.md index 8148ff1b..94ab0c6e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,54 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🧾 Beglaubigte Alt-Lücken: Dauer-Alarm im Gegenbuch beendet** (2026-08-26) + - **Ausgangslage.** Das Gegenbuch auf Prod meldete stündlich `exit=2`. Die + CRM-Prüfung lieferte `valid: false` wegen **6 struktureller Lücken** + (IDs 33, 44, 45, 922, 1434, 2583). + - **Diagnose: harmlos.** Jede Lücke liegt innerhalb eines Schwungs von + Einträgen mit **identischer Sekunde**, betrifft nur `/login` und + `/refresh`, kein Eintrag fehlt (kein 404), `tamperedEntries` leer. Das ist + die Signatur der Race-Condition, die am 19.08. mit `AuditChainLock` + geschlossen wurde (R166-01). Bestätigt durch die Zeilen selbst: Eintrag + 2583 stammt vom 21.08., ist aber noch `hashVersion=1` – auf Prod lief zu + dem Zeitpunkt also der alte Stand. + - **Das eigentliche Problem war nicht die Lücke, sondern der Dauer-Alarm.** + Diese Lücken sind nicht heilbar: die Verkettung ist gebrochen, die Inhalte + sind unversehrt. Ohne Änderung hätte das Gegenbuch für immer Alarm gemeldet + – und ein Signal, das immer schreit, warnt nicht mehr. Dieselbe Klasse wie + R162, R174, R179, R182, R183-02. + - **Lösung: Beglaubigung statt Unterdrückung.** Eine Lücke zählt nicht mehr + als offener Befund, wenn beides gilt: sie liegt im **versiegelten Bereich** + UND steht im **Vorbefund des Siegel-Markers**, also im Zustand, den der + Betreiber beim Siegeln ausdrücklich festgeschrieben hat. Der Vorbefund + liegt in `changesBefore` und ist ab Version 3 mitgehasht – die Liste lässt + sich ohne `AUDIT_HMAC_KEY` nicht nachträglich erweitern (gleiche + Absicherung wie beim Löschungs-Manifest, R171-01). + - Beglaubigt heißt **nicht verschwunden**: die Lücken bleiben in `chainGaps`, + stehen zusätzlich in neuem Feld `attestedGaps` und werden im Bericht + ausdrücklich benannt. + - **Nebenbefund derselben Klasse mitbehoben:** `backlogSealStatus` meldete + `nicht_noetig` ("Es gibt keine unsignierten Alteinträge"), wenn + `v3FromId === null` – das bedeutet aber das **Gegenteil**: dass überhaupt + nichts signiert ist, etwa weil `AUDIT_HMAC_KEY` fehlt. Ein vollständig + unsigniertes Log bekam damit Entwarnung für genau den Zustand mit der + geringsten Beweiskraft. Die Bedingung hängt jetzt an der Zahl der + unsignierten Zeilen. R183-03 (unauflösbare Warnung bei frisch signiertem + Log) bleibt behoben – per Regressionstest geprüft. + - **Getestet über HTTP gegen eine eigene Wegwerf-Datenbank**, nicht am + Service vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) → + `valid:false`; nach `seal-backlog` → `valid:true`, `attestedGaps:[5]`. + Drei Gegenproben: **neue Lücke** nach dem Siegeln → `valid:false`; + **gesiegelte Altzeile verändert** → Siegel `gebrochen`, nichts mehr + beglaubigt; **Beglaubigungsliste im Marker gefälscht** (DB-Schreibrecht, + kein Schlüssel) → Marker ungültig, Status `entfernt`, `valid:false`. + - Doku: Abschnitt „Wenn der erste Lauf `exit=2` meldet" in + `tools/audit-notary/README.md` – inklusive der Warnung, **vor** dem + Siegeln zu prüfen, was man da festschreibt. + - Dateien: `backend/src/services/audit.service.ts`, + `backend/src/controllers/auditLog.controller.ts`, + `frontend/src/services/api.ts`, `tools/audit-notary/README.md` + - [x] **🔓 Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (Pentest R184-01/-02)** (2026-08-24) - **R184-01** – Setzen war gegatet, **Entfernen nicht**. Und das Entfernen ist der gefaehrlichere Weg: Der Heartbeat-Wachhund fragt `isServiceAccount: diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 16ff6326..d4239303 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1752,7 +1752,7 @@ export const auditLogApi = { return res.data; }, verifyIntegrity: async () => { - const res = await api.post>('/audit-logs/verify'); + const res = await api.post>('/audit-logs/verify'); return res.data; }, rehash: async () => { diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index 9532addf..7cc67aac 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -86,6 +86,58 @@ soll nicht versehentlich passieren. getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln. Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`. +### Wenn der erste Lauf `exit=2` meldet: den Altbestand versiegeln + +Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** – Einträge +aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt +ist, meldet die Prüfung `valid: false`, und das Gegenbuch schlägt zu Recht +Alarm. Typischerweise steht dann im Log: + + Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären + nicht erkennbar. + +Das ist **einmalig** zu erledigen, im CRM, nicht hier: + +```bash +TOKEN=$(curl -s -X POST https:///api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"…","password":"…"}' | jq -r '.data.token') + +curl -s -X POST https:///api/audit-logs/seal-backlog \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"confirm":"SEAL"}' | jq +``` + +Dafür braucht es das Recht `audit:admin` – das Gegenbuch-Dienstkonto hat es +absichtlich **nicht**. Nimm dein Administratorkonto. + +**Zuerst nachsehen, was versiegelt wird.** Das Siegel schreibt den aktuellen +Zustand fest, samt aller vorhandenen Lücken. Wer blind siegelt, beglaubigt +gegebenenfalls auch eine Lücke, die von einer Löschung stammt. Deshalb vorher: + +```bash +curl -s -X POST https:///api/audit-logs/verify \ + -H "Authorization: Bearer $TOKEN" \ + | jq '.data | {valid, chainGaps, unexplainedGaps, tamperedEntries}' +``` + +Stehen dort Lücken, sieh dir die betroffenen IDs und ihre Nachbarn an +(`GET /api/audit-logs/`). Liegen sie jeweils **innerhalb einer Sekunde** +zusammen mit ihren Nachbarn und fehlt kein Eintrag (kein `404`), sind es +Schreibkollisionen aus paralleln Anfragen – harmlos. Fehlt dagegen ein Eintrag +oder steht etwas in `tamperedEntries`, **erst klären, dann siegeln**. + +Nach dem Siegeln meldet die Prüfung wieder `valid: true`, und die bekannten +Alt-Lücken erscheinen als **beglaubigt**: + + Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue + Lücke entstanden. 6 Lücken stammen aus der Zeit vor dem Bestandssiegel + und sind darin als Vorbefund beglaubigt (IDs …). + +Sie verschwinden also nicht aus dem Bericht – sie zählen nur nicht mehr als +offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte Altzeile +und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus. + ### Zugang einrichten (das brauchst du vorher) Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der