Neuberechnung der Kette als eigene Gegenbuch-Alarmbedingung (R186 Frage b)

Der Tester fragte praezise: Loest cleanup -> rehash OHNE erneutes Siegeln,
ueber NICHT versiegelten Inhalt, etwas Automatisches aus - oder nur die
Prosa in verify, die ein Mensch lesen muss?

Gemessen statt behauptet: Es loeste bereits aus, aber als Nebenwirkung.
Ein Rehash aendert jeden Hash, also stimmt der beglaubigte Kettenkopf
nicht mehr und der bestehende Vergleich schlug an. Das funktioniert, ist
aber ein Zufallstreffer - verschoebe sich der Anker, waere der Melder
lautlos weg. Und die Meldung hiess "Eintrag wurde veraendert" statt "die
Kette wurde neu berechnet", also Wirkung statt Ursache.

Jetzt haengt der Alarm an der Sache selbst: Das Gegenbuch fuehrt
rehashCount/rehashLast im Buch mit und meldet jede neue Neuberechnung
seit der letzten Beglaubigung mit exit 2, samt Zeitpunkt, Zeilenzahl und
dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01 (Gate
am Ausloeser statt an der Wirkung) und R185-01 (Wurzelwechsel statt
valid).

Reihenfolge geaendert, und das war noetig: Der neue Melder steht VOR dem
Kopf-Hash-Vergleich, sonst haette immer die unpraezisere Meldung
gewonnen. Und der Kopf-Vergleich wird nach einer bestaetigten
Neuberechnung uebersprungen - sonst waere die Bestaetigung wertlos, weil
ein Rehash den Kopf zwangslaeufig aendert. Beim Bauen aufgefallen, nicht
im Entwurf.

NOTARY_REHASH_ACK wird mit der ID des Rehash-Eintrags bestaetigt, nicht
mit true; IDs steigen streng, ein stehen gelassener Wert passt beim
naechsten Vorgang nicht mehr. Ein fehlender beglaubigter Eintrag
alarmiert weiterhin immer: bestaetigt wird die Neuberechnung, nicht das
Verschwinden von Zeilen.

Getestet mit echtem Gegenbuch gegen ein echtes CRM, SSH-signiertes
lokales Buch: Waesche ohne Datenbankzugriff -> CRM meldet valid:true und
chainGaps:[], Gegenbuch exit 2 mit der Neuberechnung als Ursache. Falsche
Ack-ID weiter exit 2, richtige exit 0, Folgelauf ruhig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 18:30:44 +02:00
co-authored by Claude Opus 5
parent df442bb1a0
commit 2ca6ed2f70
5 changed files with 198 additions and 2 deletions
+37
View File
@@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **⏱️ Neuberechnung der Kette ist jetzt eine eigene Gegenbuch-Alarmbedingung (Pentest R186, Frage b)** (2026-08-26)
- Der Tester fragte präzise: Löst `cleanup``rehash` **ohne** erneutes
Siegeln, über **nicht versiegelten** Inhalt, etwas **Automatisches** aus
oder nur die Prosa in `verify`, die ein Mensch lesen muss?
- **Gemessen statt behauptet:** Es löste bereits aus, aber als *Nebenwirkung*.
Ein Rehash ändert jeden Hash, also stimmt der beglaubigte Kettenkopf nicht
mehr und der bestehende Vergleich schlug an. Funktioniert ist aber ein
Zufallstreffer: Verschöbe sich der Anker, wäre der Melder lautlos weg. Und
die Meldung hieß „Eintrag wurde verändert" statt „die Kette wurde neu
berechnet", also Wirkung statt Ursache.
- Jetzt hängt der Alarm an der Sache selbst: Das Gegenbuch führt
`rehashCount`/`rehashLast` im Buch mit und meldet jede neue Neuberechnung
seit der letzten Beglaubigung mit **exit 2** samt Zeitpunkt, Zeilenzahl
und dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01
(Gate am Auslöser, nicht an der Wirkung) und R185-01 (Wurzelwechsel statt
`valid`).
- **Reihenfolge geändert, und das war nötig:** Der neue Melder steht **vor**
dem Kopf-Hash-Vergleich, sonst hätte immer die unpräzisere Meldung
gewonnen. Und der Kopf-Vergleich wird nach einer *bestätigten*
Neuberechnung übersprungen sonst wäre die Bestätigung wertlos gewesen,
weil ein Rehash den Kopf zwangsläufig ändert. Beim Bauen aufgefallen, nicht
im Entwurf.
- `NOTARY_REHASH_ACK` wird mit der **ID des Rehash-Eintrags** bestätigt, nicht
mit `true`. IDs steigen streng ein stehen gelassener Wert passt beim
nächsten Vorgang nicht mehr. Analog zu `NOTARY_SEAL_ACK`.
- Ein **fehlender** beglaubigter Eintrag alarmiert weiterhin immer: Bestätigt
wird die Neuberechnung, nicht das Verschwinden von Zeilen.
- Bestandsbücher ohne das neue Feld alarmieren nicht rückwirkend, sondern
setzen die Grundlage mit Ausgabe, statt es stillschweigend zu tun.
- **Getestet mit echtem Gegenbuch gegen ein echtes CRM** (SSH-signiertes
lokales Buch): Wäsche ohne Datenbankzugriff (3 Zeilen gelöscht → Rehash)
→ CRM meldet `valid:true`, `chainGaps:[]`; Gegenbuch **exit 2** und nennt
die Neuberechnung als Ursache. Falsche Ack-ID → weiter exit 2, richtige →
exit 0 mit neuer Grundlage, Folgelauf ruhig.
- Dateien: `tools/audit-notary/notary.mjs`,
`tools/audit-notary/{docker-compose.yml,.env.example,README.md}`
- [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26) - [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26)
- **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete - **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete
„Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem „Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem
+10
View File
@@ -67,6 +67,15 @@ PROD_ADOPT_ACK=
# durchwinken. # durchwinken.
PROD_SEAL_ACK= PROD_SEAL_ACK=
# Normalerweise leer lassen.
# Gültige Werte: die ID des Rehash-Eintrags | (leer)
# Das Gegenbuch schlägt Alarm, wenn die Hash-Kette neu berechnet wurde. Ein
# Rehash verknüpft alle Einträge neu Lücken, die eine Löschung sichtbar
# gemacht hätten, verschwinden dabei aus der Kette, und die Prüfung im CRM
# meldet danach wieder „lückenlos". War die Neuberechnung geplant, hier die
# ID eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
PROD_REHASH_ACK=
# Wo das Buch liegt relativ zu diesem Verzeichnis. # Wo das Buch liegt relativ zu diesem Verzeichnis.
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel). # DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
PROD_DIR=./data/prod PROD_DIR=./data/prod
@@ -83,4 +92,5 @@ STAGING_INTERVAL=3600
STAGING_GENESIS_ACK= STAGING_GENESIS_ACK=
STAGING_ADOPT_ACK= STAGING_ADOPT_ACK=
STAGING_SEAL_ACK= STAGING_SEAL_ACK=
STAGING_REHASH_ACK=
STAGING_DIR=./data/staging STAGING_DIR=./data/staging
+53
View File
@@ -177,6 +177,58 @@ Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der `{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten. Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
### Wenn die Kette neu berechnet wurde: ebenfalls Alarm
Ein **Rehash** verknüpft alle Einträge neu. Danach ist die Kette
zwangsläufig stimmig — auch über Löschungen hinweg, die vorher als Lücken
sichtbar gewesen wären. Die Reihenfolge `cleanup``rehash` macht aus einem
beschnittenen Protokoll ein scheinbar makelloses, und die Prüfung im CRM meldet
danach wieder „lückenlos verkettet". Beides braucht nur `audit:admin`, keinen
Datenbankzugriff.
Genau das ist im Betrieb vorgekommen: Auf einer Testinstanz wurden 3.155
Einträge gelöscht und anschließend neu berechnet. Die Prüfung war danach grün,
die 656 Kettenlücken standen nur noch im Vorbefund des Rehash-Eintrags — den
niemand liest. Das Gegenbuch war der einzige Zeuge.
Deshalb ist eine neue Neuberechnung seit der letzten Beglaubigung ein **Alarm**
(exit 2). Er nennt Zeitpunkt, Zahl der betroffenen Zeilen und den Befund, der
unmittelbar davor galt:
```
ALARM: Die Hash-Kette wurde neu berechnet (1 neuer Vorgang seit der letzten Beglaubigung).
zuletzt: 2026-08-26T16:28:25.467Z (Eintrag 13, 9 Zeilen)
Befund unmittelbar davor: 1 beanstandet, 1 Lücken
NOTARY_REHASH_ACK=13
```
**War die Neuberechnung gewollt**, bestätigst du sie mit der genannten ID:
```bash
# in der .env
PROD_REHASH_ACK=13
docker compose up -d
PROD_REHASH_ACK= # danach wieder leeren
```
Auch hier wird nicht mit `true` bestätigt, sondern mit einem Wert, der zum
Vorgang gehört. IDs steigen streng — ein stehen gelassener Wert passt bei der
nächsten Neuberechnung nicht mehr.
**Warum ein eigener Melder, wo doch schon der Kettenkopf verglichen wird?**
Eine Neuberechnung ändert jeden Hash, der beglaubigte Kopf stimmt also
ohnehin nicht mehr — der Alarm käme auch so. Aber als *Nebenwirkung*, nicht als
gebaute Warnung: Verschöbe sich der Anker irgendwann, wäre der Melder lautlos
weg. Und die Meldung hieße „Eintrag wurde verändert" statt „die Kette wurde neu
berechnet" — die Wirkung statt der Ursache. Deshalb hängt der Alarm an der
Sache selbst und steht **vor** dem Kopf-Vergleich; nach einer bestätigten
Neuberechnung wird dieser übersprungen, weil der veränderte Kopf dann die
erwartete Folge ist.
Ein fehlender beglaubigter Eintrag bleibt davon unberührt und alarmiert immer:
Bestätigt wird die Neuberechnung, nicht das Verschwinden von Zeilen.
### Zugang einrichten (das brauchst du vorher) ### Zugang einrichten (das brauchst du vorher)
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM kein Token. Der Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM kein Token. Der
@@ -556,6 +608,7 @@ an, bis er geklärt ist.
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen | | Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt `valid` allein bleibt dabei `true`** | | **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt `valid` allein bleibt dabei `true`** |
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine | | Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
| **`cleanup` + `rehash` ohne erneutes Siegeln** (Wäsche ohne Datenbankzugriff) | **neue Neuberechnung seit der letzten Beglaubigung `valid` und Kette sind danach makellos** |
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
nichts an** der manipulierte Zustand wird also nicht als neue Wahrheit nichts an** der manipulierte Zustand wird also nicht als neue Wahrheit
+3
View File
@@ -36,6 +36,8 @@ services:
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-} NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel: # Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-} NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
# Nur nach einer GEWOLLTEN Neuberechnung, mit der ID des Rehash-Eintrags:
NOTARY_REHASH_ACK: ${PROD_REHASH_ACK:-}
volumes: volumes:
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei. # Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
# Dieses Verzeichnis ist das Gegenbuch es gehört ins Backup. # Dieses Verzeichnis ist das Gegenbuch es gehört ins Backup.
@@ -56,5 +58,6 @@ services:
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-} NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-} NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-} NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
NOTARY_REHASH_ACK: ${STAGING_REHASH_ACK:-}
volumes: volumes:
- ${STAGING_DIR:-./data/staging}:/gegenbuch - ${STAGING_DIR:-./data/staging}:/gegenbuch
+95 -2
View File
@@ -153,6 +153,17 @@ const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim();
const siegelBestaetigt = (wurzel) => const siegelBestaetigt = (wurzel) =>
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK); SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
// Bestaetigung fuer eine Neuberechnung der Kette (Pentest R186, Frage (b)).
//
// Bestaetigt wird mit der ID des juengsten Rehash-Eintrags. IDs steigen
// streng, ein stehen gelassener Wert passt also beim naechsten Rehash nicht
// mehr - dasselbe selbstentwertende Prinzip wie bei NOTARY_SEAL_ACK, nur mit
// einem Wert, den man vorlesen kann.
const REHASH_ACK = (process.env.NOTARY_REHASH_ACK || '').trim();
// Wurde in diesem Lauf eine Neuberechnung ausdruecklich bestaetigt? Dann ist
// der veraenderte Kettenkopf die erwartete Folge und kein eigener Befund.
let rehashBestaetigt = false;
if (!CRM_URL) { if (!CRM_URL) {
console.error('CRM_URL muss gesetzt sein.'); console.error('CRM_URL muss gesetzt sein.');
process.exit(1); process.exit(1);
@@ -755,6 +766,17 @@ const letzter = bisher[bisher.length - 1];
if (!CRM_TOKEN) await anmelden(); if (!CRM_TOKEN) await anmelden();
const aktuell = await hole('/api/audit-logs/checkpoint'); const aktuell = await hole('/api/audit-logs/checkpoint');
// Die Vollpruefung wird IMMER geholt, auch beim allerersten Lauf: Aus ihr
// stammt die Zahl der protokollierten Neuberechnungen, und die gehoert schon
// in den ersten Eintrag - sonst gaebe es beim zweiten Lauf keine Grundlage,
// gegen die sich ein neuer Rehash abheben koennte.
const pruefung = await hole('/api/audit-logs/verify', 'POST');
const rehashListe = Array.isArray(pruefung?.rehashes) ? pruefung.rehashes : null;
const rehashAnzahl = rehashListe ? rehashListe.length : null;
const rehashLetzte = rehashListe && rehashListe.length
? rehashListe[rehashListe.length - 1].id
: null;
if (letzter) { if (letzter) {
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) { if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
alarm( alarm(
@@ -767,7 +789,6 @@ if (letzter) {
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“ // Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
// ausweisen die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt // ausweisen die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
// das Feld, nicht der Satz. // das Feld, nicht der Satz.
const pruefung = await hole('/api/audit-logs/verify', 'POST');
if (pruefung && pruefung.valid === false) { if (pruefung && pruefung.valid === false) {
alarm( alarm(
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' + 'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
@@ -779,8 +800,77 @@ if (letzter) {
} }
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`); const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
// Ein fehlender beglaubigter Eintrag ist IMMER ein Befund - auch wenn eine
// Neuberechnung bestaetigt wurde. Bestaetigt wird der Rehash, nicht das
// Verschwinden von Zeilen.
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`); if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
if (rueck.atHash !== letzter.chainHead) {
// ---------------------------------------------------------------
// Neuberechnung der Kette (Rehash) als EIGENE Alarmbedingung.
//
// Ein Rehash wurde bisher nur als Nebenwirkung gefangen: Er aendert jeden
// Hash, also stimmt der beglaubigte Kettenkopf nicht mehr und der Vergleich
// weiter oben schlaegt an. Das funktioniert - aber es ist ein Zufallstreffer
// und keine gebaute Warnung. Verschoebe sich der Anker irgendwann (anderer
// beglaubigter Wert, anderer Vergleich), waere der Melder lautlos weg.
//
// Deshalb haengt der Alarm jetzt an der Waffe selbst, nicht an ihrer Spur -
// dieselbe Lehre wie R184-01 (Gate am Ausloeser statt an der Wirkung) und
// R185-01 (Wurzelwechsel statt `valid`).
//
// Das Restrisiko, das dieser Melder abdeckt: `cleanup` + `rehash` OHNE
// erneutes Siegeln, ueber Inhalt, der gar nicht versiegelt ist. Dort greift
// weder das Bestandssiegel noch der Wurzelwechsel.
if (rehashAnzahl !== null && typeof letzter.rehashCount === 'number') {
if (rehashAnzahl > letzter.rehashCount) {
const neue = rehashAnzahl - letzter.rehashCount;
if (REHASH_ACK && String(rehashLetzte) === REHASH_ACK) {
rehashBestaetigt = true;
console.log(
`Neuberechnung bestätigt (NOTARY_REHASH_ACK=${REHASH_ACK}). ` +
'NOTARY_REHASH_ACK danach wieder leeren.',
);
} else {
const l = rehashListe[rehashListe.length - 1];
const vb = l.vorbefund;
alarm(
`Die Hash-Kette wurde neu berechnet (${neue} neue${neue === 1 ? 'r' : ''} Vorgang` +
`${neue === 1 ? '' : 'e'} seit der letzten Beglaubigung).\n` +
` zuletzt: ${l.zeitpunkt} (Eintrag ${l.id}, ${l.neuBerechnet} Zeilen)\n` +
(vb
? ` Befund unmittelbar davor: ${vb.manipuliert} beanstandet, ${vb.luecken} Lücken\n`
: ' Der Zustand davor ist nicht mehr feststellbar.\n') +
(l.signiert ? '' : ' ACHTUNG: Dieser Rehash-Eintrag trägt keine gültige Signatur.\n') +
'Ein Rehash verknüpft alle Einträge neu. Lücken, die vorher eine Löschung\n' +
'sichtbar gemacht hätten, verschwinden dabei aus der Kette die Prüfung im CRM\n' +
'meldet danach wieder „lückenlos". Genau deshalb ist das hier ein Alarm und\n' +
'kein Hinweis.\n' +
'War es geplant, bestätigen und danach wieder leeren:\n' +
` NOTARY_REHASH_ACK=${rehashLetzte}`,
);
}
}
} else if (rehashAnzahl !== null && typeof letzter.rehashCount !== 'number') {
// Gegenbuch aus der Zeit vor diesem Melder: Es gibt keine Grundlage, gegen
// die sich etwas abheben koennte. Stillschweigend zu alarmieren waere ein
// Fehlalarm bei jedem Bestandsbuch; stillschweigend zu uebernehmen waere
// eine unsichtbare Annahme. Also: uebernehmen und es sagen.
console.log(
`Grundlage für die Rehash-Überwachung wird gesetzt: ${rehashAnzahl} protokollierte ` +
'Neuberechnung(en) im CRM. Ab dem nächsten Lauf fällt jede weitere auf.',
);
}
// Der Kopf-Hash-Vergleich steht bewusst NACH der Rehash-Pruefung.
//
// Zwei Gruende: Erstens benennt der Rehash-Alarm die Ursache, waehrend
// „Eintrag wurde veraendert“ nur die Wirkung beschreibt - bei gleicher Lage
// ist die praezisere Meldung die nuetzlichere. Zweitens aendert eine
// Neuberechnung ZWANGSLAEUFIG jeden Hash; stuende dieser Vergleich davor
// oder ungeschuetzt dahinter, liesse sich eine bestaetigte Neuberechnung nie
// aufloesen und die Bestaetigung waere wertlos.
if (rueck.atHash !== letzter.chainHead && !rehashBestaetigt) {
alarm( alarm(
`Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` + `Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`, ` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
@@ -910,6 +1000,9 @@ const eintrag = {
chainHead: aktuell.chainHead, chainHead: aktuell.chainHead,
sealRoot: aktuell.sealRoot, sealRoot: aktuell.sealRoot,
sealLeafCount: aktuell.sealLeafCount, sealLeafCount: aktuell.sealLeafCount,
// Grundlage fuer die Rehash-Ueberwachung des naechsten Laufs.
rehashCount: rehashAnzahl,
rehashLast: rehashLetzte,
}; };
const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n'; const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n';