verify meldet, dass die Kette neu berechnet wurde

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 loeschten 3155 Eintraege (id 1-5356), danach lief ein Rehash.
Seither meldet die Pruefung "Alle Eintraege sind unveraendert und
lueckenlos verkettet".

Wahr - und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der
Rehash verknuepft alles neu; die rund 700 Kettenluecken, 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 Loeschung
nicht mehr feststellbar.

verifyIntegrity sammelt jetzt die Rehash-Marker; die Antwort enthaelt
rehashes[] mit Zeitpunkt, Anzahl, Signatur und Vorbefund. Die Meldung
nennt sie IMMER, auch im gruenen Fall, und der Einstiegssatz lautet dann
"...lueckenlos verkettet - allerdings erst seit der letzten
Neuberechnung".

valid bleibt unberuehrt. Ein Rehash ist eine legitime Massnahme; ihn
dauerhaft als Befund zu fuehren waere der Dauer-Alarm, den wir mit den
beglaubigten Luecken gerade beseitigt haben. Melden, nicht alarmieren.

Umgekehrte Beweislast als bei Manifest und Siegel: Dort zaehlen nur
signierte Traeger, weil ein gefaelschter Marker Luecken wegerklaeren
koennte. Hier erzeugt ein Marker eine Warnung - wuerden nur signierte
zaehlen, koennte man einen Rehash unsichtbar machen, indem man seine
Signatur zerstoert. Deshalb zaehlt jeder auswertbare Marker; eine
fehlende Signatur wird zusaetzlich gemeldet.

Getestet ueber HTTP gegen eine Wegwerf-DB mit echtem Rehash ueber den
regulaeren Endpunkt: sauberer Vorzustand, Loeschung+Rehash (der
Staging-Ablauf im Kleinen), und zerstoerte Signatur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 17:59:25 +02:00
co-authored by Claude Opus 5
parent 31c4c209e4
commit df442bb1a0
5 changed files with 213 additions and 4 deletions
+50 -2
View File
@@ -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,
+81
View File
@@ -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,
+36
View File
@@ -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 15356), 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
@@ -126,7 +126,12 @@ export default function AuditIntegrityCard() {
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
<AmpelIcon ampel={ampel} />
<div className="min-w-0">
<p className={`font-medium ${stil.titel}`}>{AMPEL_TITEL[ampel]}</p>
<p className={`font-medium ${stil.titel}`}>
{AMPEL_TITEL[ampel]}
{ampel === 'ok' && ergebnis.rehashes.length > 0 && (
<span className="font-normal"> seit der letzten Neuberechnung</span>
)}
</p>
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
<p className="text-sm text-gray-500 mt-2">
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
@@ -156,9 +161,36 @@ export default function AuditIntegrityCard() {
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
{ergebnis.tamperedEntries.length === 0 &&
ergebnis.chainGaps.length === 0 &&
ergebnis.unverifiableEntries.length === 0 && (
ergebnis.unverifiableEntries.length === 0 &&
ergebnis.rehashes.length === 0 && (
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
)}
{ergebnis.rehashes.length > 0 && (
<div className="pt-2 border-t mt-2">
<p className="text-sm font-medium text-gray-700">Neuberechnungen der Kette</p>
{ergebnis.rehashes.map((r) => (
<p key={r.id} className="text-sm text-gray-600 mt-1">
{new Date(r.zeitpunkt).toLocaleString('de-DE')} · Eintrag {r.id}
{r.neuBerechnet !== null && ` · ${r.neuBerechnet} Einträge neu verkettet`}
{r.vorbefund && (
<span className="text-gray-500">
{' '}· davor: {r.vorbefund.manipuliert} beanstandet,{' '}
{r.vorbefund.luecken} Lücken
</span>
)}
{!r.signiert && (
<span className="text-red-700"> · ohne gültige Signatur</span>
)}
</p>
))}
<p className="text-xs text-gray-500 mt-2">
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.
</p>
</div>
)}
<p className="text-xs text-gray-500 pt-2">
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
mehr zu ihrer Prüfsumme das ist ernst.
+12
View File
@@ -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';