diff --git a/docs/todo.md b/docs/todo.md index 96c467ff..0099d21d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,36 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **👀 Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (Pentest R179 a/b)** (2026-08-18) + - **(a) Probe-Push verworfen – seine Analyse war zwingend.** Ein Testpush + haette (1) nur den geprobten Ref beurteilt, nicht `main`; (2) die EIGENE + Push-Identitaet gemessen statt die des Angreifers – Bypass-Rechte fuer + Admins gehen genau dann auseinander, wenn es zaehlt; (3) nur einen + Zeitpunkt abgedeckt und damit die Praesenz-erzeugt-Zuversicht-Falle aus + R179-01 neu gebaut. Und: einen zerstoerungsfreien Force-Push gibt es nicht – + die bestaetigende Beobachtung waere derselbe Vorgang wie der Schaden. + - Stattdessen **Fast-Forward-Waechter**: Bei jedem Lauf wird der beobachtete + Remote-Kopf **ausserhalb des Klons** festgehalten + (`~/.opencrm-notary/beobachtungen.jsonl`, per `NOTARY_STATE_FILE` + aenderbar). Beim naechsten Lauf muss der neue Kopf ein Nachfahre des alten + sein – sonst Alarm. Das erkennt das **Ereignis** statt die Regel + abzufragen und wirkt unabhaengig davon, ob serverseitiger Schutz existiert. + Ein belegter Fast-Forward gilt als Nachweis und blendet den Rewind-Vorbehalt + aus (im Gegensatz zur blossen Praesenz eines MIN_SEQ-Werts). + - **(b) Code 3 als Tor VOR dem Anhaengen, nicht als Status danach.** Sein + Argument: Der Schreiblauf signiert mit dem neuen Checkpoint zugleich ueber + den Bestand darunter – ist die Basis ungeklaert, waere das Anhaengen selbst + das Waschmittel. Jetzt: Grundlage nicht feststellbar → **nichts anhaengen, + exit 3**. Erster Lauf ueberhaupt → Basislinie setzen, aber ehrlich als + solche melden (exit 3, kein gruenes Licht). Anhaengen geklappt, Push + gescheitert → **exit 3 mit „erstellt, aber NICHT verankert“** statt 0 oder + 1. Nur Anhaengen + Push + belegte Verankerung → exit 0. + - Verifiziert: Basislinie → exit 3; Folgelauf → exit 0 ohne Vorbehalt; + **Rewind aus einem FRISCHEN Auditoren-Klon ohne MIN_SEQ und ohne + Zusicherung → Alarm exit 2** (das war bisher stilles Gruen); Push-Ziel + kaputt → „erstellt, aber NICHT verankert“ exit 3, Folgelauf haelt den + ungepushten Commit fail-closed an (exit 2). Labor und Port geraeumt. + - [x] **🚦 Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)** (2026-08-18) - **R179-b – der wichtigere Punkt:** Mein Rewind-Vorbehalt stand in der Ausgabe, der Exit blieb **0**. Meine eigene README sagt „fuer Cron gilt: diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index e3b1dd25..ab48a369 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -61,6 +61,29 @@ Deshalb zusätzlich: NOTARY_MIN_SEQ=42 node notary.mjs --check ``` +## Der Rewind-Wächter + +Das Skript merkt sich bei jedem Lauf den beobachteten Remote-Kopf in einer +Datei **außerhalb des Klons** (Standard `~/.opencrm-notary/beobachtungen.jsonl`, +per `NOTARY_STATE_FILE` änderbar). Beim nächsten Lauf muss der neue Kopf ein +Nachfahre des alten sein. Ist er das nicht, wurde zurückgespult – und zwar +**unabhängig davon, ob es einen serverseitigen Schutz gibt**. + +Das ist der Grund, warum hier *kein* Probe-Push stattfindet: Ein solcher Test +würde die eigene Push-Identität messen, nicht die des Angreifers (Bypass-Rechte +für Administratoren gehen genau dann auseinander, wenn es zählt), er gälte nur +für den geprobten Ref, und die bestätigende Beobachtung wäre derselbe Vorgang +wie der Schaden. Deshalb wird das **Ereignis** erkannt statt die Regel +abgefragt. + +Zwei Konsequenzen für den Betrieb: + +- **Die Datei gehört nicht in den Klon** und sollte möglichst auf getrenntem + Speicher liegen. Geht sie verloren, beginnt die Beobachtung von vorn – der + erste Lauf danach meldet ehrlich „erste Beobachtung", nicht „alles gut". +- **Der allererste Lauf endet mit Code 3.** Was man nie gesehen hat, kann man + nicht vergleichen. Das ist kein Fehler, sondern die ehrliche Auskunft. + **`NOTARY_MIN_SEQ` ersetzt die serverseitige Sperre nicht.** Der Wert ist eine *Untergrenze* und immer nur so frisch wie deine letzte Beobachtung. Wer stündlich beglaubigt, aber wöchentlich prüft, läuft mit einem Wert herum, der @@ -131,7 +154,7 @@ Deshalb gilt jetzt: | 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) | | 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) | | 2 | **Befund** – Widerspruch zwischen CRM und Gegenbuch, oder ungültige Signatur | -| 3 | beglaubigter Stand nicht abschließend feststellbar – Remote fehlt/unerreichbar, **oder** ein Zurückspulen lässt sich nicht ausschließen | +| 3 | beglaubigter Stand nicht abschließend feststellbar – Remote fehlt/unerreichbar, erste Beobachtung, Zurückspulen nicht ausschließbar, **oder** Checkpoint erstellt aber nicht verankert | Für Cron gilt: **jeder** Code außer 0 gehört gemeldet. Code 2 ist der Alarm, Code 3 heißt „ich weiß es nicht" – und das ist ausdrücklich kein Freibrief. @@ -145,6 +168,19 @@ node notary.mjs --check Führt alle Kontrollen aus, hängt aber nichts an und braucht kein Schreibrecht. Geeignet für jemanden, der die Kette unabhängig nachvollziehen will. +## Anhängen ist selbst ein Beglaubigungsakt + +Ein Schreiblauf erweitert nicht nur die Kette – er signiert damit zugleich über +alles darunter. Deshalb prüft das Skript **vor** dem Anhängen und verweigert +es, wenn die Grundlage nicht feststeht. Sonst wäre das Anhängen selbst das +Waschmittel: eine frische Signatur über einen ungeklärten Vorzustand beglaubigt +diesen mit. + +Ebenso gilt: **erstellt ist nicht verankert.** Klappt der Push nicht, endet der +Lauf mit Code 3 und der ausdrücklichen Auskunft „erstellt, aber NICHT +verankert" – niemals mit 0. Der nächste Lauf hält den ungepushten Commit dann +an, bis er geklärt ist. + ## Was das Skript erkennt | Angriff | Erkennung | @@ -153,6 +189,7 @@ Geeignet für jemanden, der die Kette unabhängig nachvollziehen will. | Einträge am Ende abgeschnitten | aktuelle höchste ID kleiner als die beglaubigte | | Bestandssiegel verschwunden | vorher beglaubigt, jetzt nicht mehr vorhanden | | Gegenbuch selbst gekürzt | Lücke in der fortlaufenden Nummer | +| **Reihe zurückgespult (Force-Push)** | **Remote-Kopf ist kein Nachfahre des zuletzt beobachteten** | | Gegenbuch lokal manipuliert | Arbeitsdatei weicht vom signierten Stand ab | | Untergeschobener Commit | Commit ohne gültige Signatur in der Historie | | Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf | diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index bfa6c5cd..a5f8513c 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -23,8 +23,9 @@ * fehl, wird die Datei zurueckgerollt (Pentest R175-03). */ import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { resolve, basename } from 'node:path'; +import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'; +import { resolve, basename, join, dirname } from 'node:path'; +import { homedir } from 'node:os'; const CRM_URL = process.env.CRM_URL; const CRM_TOKEN = process.env.CRM_TOKEN; @@ -102,6 +103,27 @@ if (MIN_SEQ_ROH) { // ~168 hinterherhinkt – jeder Rewind in dieses Fenster passiert lautlos, und // der gewaschene Stand wird sogar ausdruecklich bestaetigt. const REWIND_GESCHUETZT = process.env.NOTARY_REWIND_PROTECTED === 'true'; + +// Beobachtungsspeicher fuer den Fast-Forward-Monitor (Pentest R179 (a)). +// +// Statt die POLICY abzufragen (Probe-Push), wird das EREIGNIS erkannt, das die +// Policy verhindern soll. Bei jedem Lauf wird der beobachtete Remote-Kopf +// festgehalten; beim naechsten Mal muss der neue Kopf ein Nachfahre des alten +// sein. Ist er das nicht, wurde zurueckgespult – unabhaengig davon, ob es einen +// serverseitigen Schutz gibt oder nicht. +// +// Ein Probe-Push waere hier der falsche Weg: Er misst die eigene +// Push-Identitaet, nicht die des Angreifers (Bypass-Rechte fuer Admins gehen +// genau dann auseinander, wenn es darauf ankommt), er gilt nur fuer den +// geprobten Ref, und die bestaetigende Beobachtung waere derselbe Vorgang wie +// der Schaden. +// +// Die Datei gehoert bewusst NICHT in den Klon – sonst nimmt ein Neu-Klonen ihr +// die Erinnerung. Idealerweise liegt sie auf separatem Speicher. +const STATE_FILE = resolve( + process.env.NOTARY_STATE_FILE || + join(homedir(), '.opencrm-notary', 'beobachtungen.jsonl'), +); if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschuetzt-ist') { console.error( 'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' + @@ -206,7 +228,7 @@ const OHNE_ANKER = () => // signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung, // sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist. const REWIND_VORBEHALT = () => - REWIND_GESCHUETZT + REWIND_GESCHUETZT || ffBelegt ? '' : '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' + 'lässt sich hier nicht ausschließen.' + @@ -228,7 +250,11 @@ const REWIND_VORBEHALT = () => // feststellbar“, dieselbe Bedeutung wie beim unerreichbaren Remote und // bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. const abschliessendSicher = (anzahl) => - REWIND_GESCHUETZT || (Number.isFinite(MIN_SEQ) && anzahl >= MIN_SEQ); + REWIND_GESCHUETZT || + // Belegte Fast-Forward-Kette seit der ersten Beobachtung: das ist ein + // Nachweis, kein Versprechen – deshalb zaehlt er (R179 (a)). + ffBelegt || + (Number.isFinite(MIN_SEQ) && anzahl >= MIN_SEQ); const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); }; @@ -298,6 +324,70 @@ if (ref !== 'HEAD') { } } +// --------------------------------------------------------------------------- +// 1b) Fast-Forward-Monitor: wurde der Remote-Kopf zurueckgespult? +// --------------------------------------------------------------------------- +const remoteKopf = git('rev-parse', ref); +const remoteKennung = gitStill('remote', 'get-url', REMOTE).out || zweig; + +function beobachtungenLesen() { + if (!existsSync(STATE_FILE)) return []; + return readFileSync(STATE_FILE, 'utf8') + .split('\n') + .filter((z) => z.trim()) + .map((z) => { + try { return JSON.parse(z); } catch { return null; } + }) + .filter((e) => e && e.remote === remoteKennung && e.zweig === zweig); +} + +const beobachtungen = beobachtungenLesen(); +const letzteBeobachtung = beobachtungen[beobachtungen.length - 1]; +let ffBelegt = false; + +if (letzteBeobachtung) { + if (letzteBeobachtung.sha === remoteKopf) { + ffBelegt = true; + } else { + // Ist der zuletzt gesehene Kopf ein Vorfahre des jetzigen? Dann ist die + // Kette gewachsen. Sonst wurde sie zurueckgespult oder ersetzt. + const vorfahre = gitStill('merge-base', '--is-ancestor', letzteBeobachtung.sha, remoteKopf); + if (vorfahre.ok) { + ffBelegt = true; + } else { + alarm( + `Der Remote-Kopf ist KEIN Nachfahre des zuletzt beobachteten Standes – ` + + `die Reihe wurde zurückgespult oder ersetzt.\n` + + ` zuletzt gesehen: ${letzteBeobachtung.sha.slice(0, 12)} (${letzteBeobachtung.ts})\n` + + ` jetzt : ${remoteKopf.slice(0, 12)}\n` + + `Genau das soll der serverseitige Rewind-Schutz verhindern. Prüfen, wer ` + + `force-pushen konnte – und ob die Datenbank dazu passend gekürzt wurde.`, + ); + } + } +} + +const beobachtungMerken = () => { + try { + mkdirSync(dirname(STATE_FILE), { recursive: true }); + appendFileSync( + STATE_FILE, + JSON.stringify({ + ts: new Date().toISOString(), + remote: remoteKennung, + zweig, + sha: git('rev-parse', ref), + }) + '\n', + ); + } catch (e) { + console.warn( + `HINWEIS: Beobachtung konnte nicht festgehalten werden (${STATE_FILE}).\n` + + ` Ohne sie erkennt der nächste Lauf ein Zurückspulen nicht. Grund: ` + + `${e instanceof Error ? e.message : String(e)}`, + ); + } +}; + // --------------------------------------------------------------------------- // 2) Signaturen der GESAMTEN Historie pruefen – das war der fehlende Leser. // --------------------------------------------------------------------------- @@ -436,9 +526,32 @@ if (NUR_PRUEFEN) { (letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.') + OHNE_ANKER() + REWIND_VORBEHALT(), ); + beobachtungMerken(); process.exit(sicher ? 0 : 3); } +// --------------------------------------------------------------------------- +// 5) Tor VOR dem Anhaengen (Pentest R179 (b)). +// +// Der Schreiblauf erweitert nicht nur die Kette – er signiert damit zugleich +// ueber den Bestand darunter. Ist die Basis nicht feststellbar, waere das +// Anhaengen selbst das Waschmittel: eine frische Signatur ueber einen +// ungeklaerten Vorzustand beglaubigt alles darunter mit. +// +// Ausnahme ist der allererste Lauf: Was man nie gesehen hat, kann man nicht +// verifizieren. Diese Basislinie wird gesetzt, aber ehrlich als solche +// gemeldet – nicht als gruenes Licht. +// --------------------------------------------------------------------------- +const basislinie = !letzteBeobachtung; +if (!abschliessendSicher(bisher.length) && !basislinie) { + console.error( + 'Die Grundlage für einen neuen Checkpoint ist nicht abschließend feststellbar –\n' + + 'es wird nichts angehängt. Ein Checkpoint über einen ungeklärten Vorzustand würde\n' + + 'diesen mitbeglaubigen.' + REWIND_VORBEHALT(), + ); + process.exit(3); +} + const eintrag = { seq: bisher.length + 1, ts: aktuell.ts, @@ -495,19 +608,36 @@ if (SIGN) { } if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert – das Gegenbuch ist ungeschützt.'); +let verankert = false; if (PUSH && !NUR_LOKAL) { const p = gitStill('push', REMOTE, zweig); if (!p.ok) { + // NICHT exit 0 und nicht exit 1: Der Checkpoint wurde zwar erstellt, ist + // aber nicht verankert. „Mechanisch erweitert“ ist nicht „nachweislich + // verankert“ – diese Verwechslung waere der Waschgang (R179 (b)). console.error( - `Push fehlgeschlagen – der Checkpoint liegt nur lokal und gilt nicht als beglaubigt.\n${p.out}`, + `Checkpoint ${eintrag.seq} wurde erstellt, aber NICHT verankert – der Push ist ` + + `fehlgeschlagen.\n` + + `Er liegt nur lokal und zählt nicht als beglaubigt. Der nächste Lauf wird ihn als ` + + `"nie gepusht" melden, bis er verankert ist.\n git-Meldung: ${p.out}`, ); - process.exit(1); + process.exit(3); } + verankert = true; + beobachtungMerken(); +} else if (NUR_LOKAL) { + beobachtungMerken(); } +const basisHinweis = basislinie + ? '\n Erste Beobachtung dieses Gegenbuchs – ab jetzt fällt ein Zurückspulen auf. ' + + 'Für diesen Lauf selbst gibt es noch nichts zu vergleichen.' + : ''; + console.log( - `OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).` + OHNE_ANKER() + - REWIND_VORBEHALT() + + `${verankert || NUR_LOKAL ? 'OK' : 'HINWEIS'}: Checkpoint ${eintrag.seq} ` + + `${verankert ? 'beglaubigt' : 'erstellt'} (maxId ${eintrag.maxId}).` + + OHNE_ANKER() + REWIND_VORBEHALT() + basisHinweis + `\n Zuletzt bekannte Nummer für die nächste Prüfung: NOTARY_MIN_SEQ=${eintrag.seq}`, ); -process.exit(abschliessendSicher(eintrag.seq) ? 0 : 3); +process.exit(!basislinie && abschliessendSicher(eintrag.seq) ? 0 : 3);