diff --git a/docs/todo.md b/docs/todo.md index 9592d098..710b6a7a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ 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) - **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete „Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem diff --git a/tools/audit-notary/.env.example b/tools/audit-notary/.env.example index d72fc03c..c6928ba6 100644 --- a/tools/audit-notary/.env.example +++ b/tools/audit-notary/.env.example @@ -67,6 +67,15 @@ PROD_ADOPT_ACK= # durchwinken. 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. # DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel). PROD_DIR=./data/prod @@ -83,4 +92,5 @@ STAGING_INTERVAL=3600 STAGING_GENESIS_ACK= STAGING_ADOPT_ACK= STAGING_SEAL_ACK= +STAGING_REHASH_ACK= STAGING_DIR=./data/staging diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index 40b9ae88..da0bbb46 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -177,6 +177,58 @@ Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt `{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der 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) 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 | | **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 | +| **`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 nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit diff --git a/tools/audit-notary/docker-compose.yml b/tools/audit-notary/docker-compose.yml index fb8c60bd..256fa9b0 100644 --- a/tools/audit-notary/docker-compose.yml +++ b/tools/audit-notary/docker-compose.yml @@ -36,6 +36,8 @@ services: NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-} # Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel: NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-} + # Nur nach einer GEWOLLTEN Neuberechnung, mit der ID des Rehash-Eintrags: + NOTARY_REHASH_ACK: ${PROD_REHASH_ACK:-} volumes: # Buch, Schlüssel, Beobachtungsspeicher und Statusdatei. # Dieses Verzeichnis ist das Gegenbuch – es gehört ins Backup. @@ -56,5 +58,6 @@ services: NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-} NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-} NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-} + NOTARY_REHASH_ACK: ${STAGING_REHASH_ACK:-} volumes: - ${STAGING_DIR:-./data/staging}:/gegenbuch diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index a03acf74..e43a652e 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -153,6 +153,17 @@ const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim(); const siegelBestaetigt = (wurzel) => 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) { console.error('CRM_URL muss gesetzt sein.'); process.exit(1); @@ -755,6 +766,17 @@ const letzter = bisher[bisher.length - 1]; if (!CRM_TOKEN) await anmelden(); 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 (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) { alarm( @@ -767,7 +789,6 @@ if (letzter) { // Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“ // ausweisen – die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt // das Feld, nicht der Satz. - const pruefung = await hole('/api/audit-logs/verify', 'POST'); if (pruefung && pruefung.valid === false) { alarm( '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}`); + // 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 !== 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( `Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` + ` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`, @@ -910,6 +1000,9 @@ const eintrag = { chainHead: aktuell.chainHead, sealRoot: aktuell.sealRoot, 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';