Beglaubigte Alt-Luecken: Dauer-Alarm im Gegenbuch beendet

Das Gegenbuch auf Prod meldete stuendlich exit=2, weil die CRM-Pruefung
wegen 6 struktureller Luecken valid:false lieferte (IDs 33, 44, 45, 922,
1434, 2583).

Diagnose: harmlos. Jede Luecke liegt innerhalb eines Schwungs von
Eintraegen mit identischer Sekunde, betrifft nur /login und /refresh,
kein Eintrag fehlt, tamperedEntries ist leer. Das ist die Signatur der
Race-Condition, die am 19.08. mit AuditChainLock geschlossen wurde
(R166-01). Die Zeilen datieren ihren eigenen Code: Eintrag 2583 stammt
vom 21.08., ist aber noch hashVersion=1.

Das eigentliche Problem war nicht die Luecke, sondern der Dauer-Alarm.
Diese Luecken sind nicht heilbar - die Verkettung ist gebrochen, die
Inhalte sind unversehrt. Ohne Aenderung haette das Gegenbuch fuer immer
Alarm gemeldet, und ein Signal, das immer schreit, warnt nicht mehr.
Dieselbe Klasse wie R162, R174, R179, R182, R183-02.

Loesung: Beglaubigung statt Unterdrueckung. Eine Luecke zaehlt nicht mehr
als offener Befund, wenn sie im versiegelten Bereich liegt UND im
Vorbefund des Siegel-Markers steht - also im Zustand, den der Betreiber
beim Siegeln ausdruecklich festgeschrieben hat. Der Vorbefund liegt in
changesBefore und ist ab Version 3 mitgehasht; ohne AUDIT_HMAC_KEY laesst
sich die Liste nicht nachtraeglich erweitern (gleiche Absicherung wie
beim Loeschungs-Manifest, R171-01). Beglaubigt heisst nicht verschwunden:
die Luecken bleiben in chainGaps, stehen zusaetzlich in attestedGaps und
werden im Bericht ausdruecklich benannt.

Nebenbefund derselben Klasse mitbehoben: backlogSealStatus meldete
"nicht_noetig" ("Es gibt keine unsignierten Alteintraege"), wenn
v3FromId === null - das bedeutet aber das Gegenteil, naemlich dass
ueberhaupt nichts signiert ist, etwa weil AUDIT_HMAC_KEY fehlt. Ein
vollstaendig unsigniertes Log bekam damit Entwarnung fuer genau den
Zustand mit der geringsten Beweiskraft. Die Bedingung haengt jetzt an der
Zahl der unsignierten Zeilen; R183-03 bleibt behoben (Regressionstest).

Getestet ueber 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 Luecke nach dem Siegeln -> valid:false; gesiegelte
Altzeile veraendert -> Siegel gebrochen, nichts mehr beglaubigt;
Beglaubigungsliste im Marker gefaelscht (DB-Schreibrecht, kein
Schluessel) -> Marker ungueltig, Status entfernt, valid:false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 09:07:26 +02:00
co-authored by Claude Opus 5
parent e504b8be96
commit ecaeae48d4
5 changed files with 207 additions and 13 deletions
+36 -9
View File
@@ -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,
+70 -3
View File
@@ -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<number>();
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,
+48
View File
@@ -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:
+1 -1
View File
@@ -1752,7 +1752,7 @@ export const auditLogApi = {
return res.data;
},
verifyIntegrity: async () => {
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; unverifiableEntries: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; attestedGaps: number[]; unverifiableEntries: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
return res.data;
},
rehash: async () => {
+52
View File
@@ -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://<crm>/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"…","password":"…"}' | jq -r '.data.token')
curl -s -X POST https://<crm>/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://<crm>/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/<id>`). 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