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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user