diff --git a/docs/todo.md b/docs/todo.md index 0099d21d..4bb5c245 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🧠 Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (Pentest R180)** (2026-08-18) + - Sein Durchrechnen war zwingend: „erste Beobachtung → exit 3“ galt **genau + einen Lauf**. Danach baselinete der Waechter auf den AKTUELLEN Kopf – nach + einem Rewind also auf den gewaschenen Stand – und meldete dauerhaft gruen. + Der Angreifer musste nur **ein einziges exit 3** ueberstehen, ausgerechnet + den Code, den der Betreiber bei Remote-Ausfaellen ohnehin staendig sieht. + Damit war der Waechter, der Rewind OHNE Notar-Host-Integritaet fangen + sollte, wieder an genau diese Integritaet gekoppelt. + - **Eigentlicher Fix (sein Vorschlag): Gedaechtnis ins Append-only-Substrat.** + Jeder verankerte Checkpoint bekommt einen eigenen Ref + `refs/notary/seq-N`. Der ueberlebt einen Force-Push auf den Zweig, weil er + ein anderer Ref ist – die hoechste je existierende Nummer ist damit aus dem + Server rekonstruierbar. Geprueft wird: ist der hoechste verankerte + Checkpoint noch im aktuellen Kopf enthalten, und ist die Reihe mindestens + so lang wie verankert. + - **Laute Verlustbehandlung mit seinem Diskriminator** („traegt der Remote + schon Checkpoints?“): keine Historie → echte Genesis, einmalig + `NOTARY_GENESIS_ACK=true`; Historie vorhanden, aber kein Gedaechtnis → + **Anomalie**, eigener **Code 4**, **keine stille Adoption**, erst nach + `NOTARY_ADOPT_ACK=true`. Eine Baseline zu setzen heisst „ich verbuerge + mich, DAS ist der wahre Kopf“ – dieser Akt wird nicht automatisch + wiederholt. + - Verifiziert: Genesis ohne Bestaetigung → **Code 4**; mit Bestaetigung → + Kette aufgebaut, `refs/notary/seq-1..5` auf dem Server; Rewind auf + Checkpoint 2 → Alarm **exit 2**, und zwar **auch nach Loeschen des lokalen + Speichers** und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft + gruen); zusaetzlich die Anker-Refs geloescht → **Code 4** statt stiller + Uebernahme, erst nach ausdruecklicher Bestaetigung weiter (dann exit 3 mit + Vorbehalt, nicht gruen). Labor und Port geraeumt. + - **Ehrlich dokumentiert:** Die Anker-Refs muessen serverseitig ebenfalls vor + Loeschen/Ueberschreiben geschuetzt sein – sonst verschiebt sich das Problem + nur eine Ebene weiter. Server-API-Abfrage der Protection bleibt bewusst + weg (er hat sie selbst nur als Plausibilisierung eingeordnet). + - [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 diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index ab48a369..f2643b4b 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -76,6 +76,36 @@ 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. +### Das Gedächtnis liegt im Server, nicht nur auf diesem Rechner + +Jeder verankerte Checkpoint bekommt zusätzlich einen eigenen unveränderlichen +Ref `refs/notary/seq-N`. Der überlebt einen Force-Push auf `main`, weil er ein +**anderer Ref** ist. Damit lässt sich die höchste je existierende Nummer aus dem +Server rekonstruieren – auch wenn der lokale Beobachtungsspeicher fehlt. + +Warum das nötig ist: Läge das Gedächtnis nur lokal, müsste ein Angreifer nach +einem Zurückspulen bloß diese Datei löschen. Der nächste Lauf meldete dann +„erste Beobachtung" – **ein einziges** Code-3, das im Alltag neben echten +Remote-Ausfällen untergeht – und würde anschließend den zurückgespulten Stand +als Ausgangspunkt übernehmen. Ab da wäre alles dauerhaft grün. Ein gutartiger +Standardzustand, den ein Angreifer gezielt herstellen kann, ist kein sicherer +Standardzustand. + +> **Diese Refs gehören ebenfalls serverseitig geschützt** (kein Löschen, kein +> Überschreiben). Sonst verschiebt sich das Problem nur eine Ebene weiter. + +### Wenn das Gedächtnis trotzdem fehlt + +Das Skript unterscheidet zwei Fälle – der Unterschied entscheidet alles: + +| Lage | Bedeutung | Verhalten | +|---|---|---| +| kein Speicher, **keine** notarisierte Historie | echte Erstinbetriebnahme | einmalig `NOTARY_GENESIS_ACK=true`, sonst Code 4 | +| kein Speicher, **aber** Historie vorhanden | Gedächtnis ging *nach* der Etablierung verloren | **Code 4**, keine stille Übernahme; erst nach `NOTARY_ADOPT_ACK=true` | + +Eine Baseline zu setzen heißt „ich verbürge mich, *das* ist der wahre Kopf". +Dieser Akt wird nicht automatisch wiederholt. + Zwei Konsequenzen für den Betrieb: - **Die Datei gehört nicht in den Klon** und sollte möglichst auf getrenntem @@ -154,6 +184,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 | +| 4 | **Wächter-Gedächtnis fehlt** – Erstinbetriebnahme unbestätigt, oder Speicher nach der Etablierung verloren | | 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, diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index a5f8513c..2b6148db 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -228,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 || ffBelegt + REWIND_GESCHUETZT || ankerBelegt || ffBelegt ? '' : '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' + 'lässt sich hier nicht ausschließen.' + @@ -251,6 +251,9 @@ const REWIND_VORBEHALT = () => // bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. const abschliessendSicher = (anzahl) => REWIND_GESCHUETZT || + // Verankerte Refs sind das belastbarste Signal: sie liegen im Substrat und + // ueberleben einen Force-Push auf den Zweig. + ankerBelegt || // Belegte Fast-Forward-Kette seit der ersten Beobachtung: das ist ein // Nachweis, kein Versprechen – deshalb zaehlt er (R179 (a)). ffBelegt || @@ -325,9 +328,54 @@ if (ref !== 'HEAD') { } // --------------------------------------------------------------------------- -// 1b) Fast-Forward-Monitor: wurde der Remote-Kopf zurueckgespult? +// 1a) Anker-Refs: das Gedaechtnis liegt im Substrat, nicht auf diesem Rechner. +// +// Jeder verankerte Checkpoint bekommt zusaetzlich einen eigenen Ref +// `refs/notary/seq-N`. Der ueberlebt einen Force-Push auf `main`, weil er ein +// anderer Ref ist. Damit ist die hoechste je existierende Nummer aus dem +// Remote rekonstruierbar – auch dann, wenn der lokale Beobachtungsspeicher +// fehlt. +// +// Das schliesst die Luecke, die ein reiner Host-Speicher offenlaesst: Ein +// Angreifer, der ihn loescht, brauchte sonst nur EIN „erste Beobachtung“ +// zu ueberstehen – danach haette der Waechter den zurueckgespulten Stand als +// Wahrheit adoptiert und dauerhaft gruen gemeldet. Ein gutartiger Default, den +// ein Angreifer gezielt erzeugen kann, ist kein sicherer Default. // --------------------------------------------------------------------------- +const ankerRefs = (() => { + const r = gitStill('ls-remote', REMOTE, 'refs/notary/seq-*'); + if (!r.ok || !r.out) return []; + return r.out + .split('\n') + .map((z) => z.trim().split(/\s+/)) + .filter((t) => t.length === 2) + .map(([sha, name]) => ({ sha, seq: Number.parseInt(name.replace(/.*seq-/, ''), 10) })) + .filter((e) => Number.isFinite(e.seq)) + .sort((a, b) => a.seq - b.seq); +})(); +const hoechsterAnker = ankerRefs[ankerRefs.length - 1] || null; + const remoteKopf = git('rev-parse', ref); + +let ankerBelegt = false; +if (hoechsterAnker) { + // Der beglaubigte Checkpoint muss im aktuellen Kopf noch enthalten sein. + // Ist er es nicht, wurde `main` an ihm vorbei zurueckgespult – und das faellt + // hier auf, ganz ohne lokalen Speicher. + const drin = + hoechsterAnker.sha === remoteKopf || + gitStill('merge-base', '--is-ancestor', hoechsterAnker.sha, remoteKopf).ok; + if (!drin) { + alarm( + `Der verankerte Checkpoint ${hoechsterAnker.seq} ist im aktuellen Stand nicht mehr ` + + `enthalten – die Reihe wurde an ihm vorbei zurückgespult.\n` + + ` verankert: ${hoechsterAnker.sha.slice(0, 12)} (refs/notary/seq-${hoechsterAnker.seq})\n` + + ` jetzt : ${remoteKopf.slice(0, 12)}\n` + + `Diese Refs überleben einen Force-Push auf ${zweig} – deshalb fällt es auf.`, + ); + } + ankerBelegt = true; +} const remoteKennung = gitStill('remote', 'get-url', REMOTE).out || zweig; function beobachtungenLesen() { @@ -345,6 +393,49 @@ const beobachtungen = beobachtungenLesen(); const letzteBeobachtung = beobachtungen[beobachtungen.length - 1]; let ffBelegt = false; +// --------------------------------------------------------------------------- +// 1c) Fehlt das Waechter-Gedaechtnis – und ist das harmlos? +// +// „Kein Speicher“ ist zweierlei, und der Unterschied entscheidet alles: +// * Es gab hier noch nie etwas → echte Erstinbetriebnahme, legitim. +// * Es gibt bereits eine notarisierte Historie, aber kein Gedaechtnis mehr +// → das Gedaechtnis ging NACH der Etablierung verloren. Das ist die +// Anomalie, nicht der Normalfall – und genau der Zustand, den ein +// Angreifer nach einem Rewind gezielt herstellt, um die naechste Baseline +// auf seinen Stand zu setzen. +// +// Deshalb: kein stilles Neu-Baselinen. Eine Baseline zu setzen heisst „ich +// verbuerge mich, DAS ist der wahre Kopf“ – dieser Akt gehoert bestaetigt. +// --------------------------------------------------------------------------- +if (!letzteBeobachtung && !ankerBelegt) { + const remoteHatHistorie = gitStill('show', `${ref}:${LEDGER_NAME}`).ok; + if (remoteHatHistorie && process.env.NOTARY_ADOPT_ACK !== 'true') { + console.error( + 'Das Wächter-Gedächtnis fehlt, obwohl bereits eine notarisierte Historie existiert.\n' + + 'Das ist KEIN Normalzustand: Entweder wurde dieser Rechner neu aufgesetzt, oder der\n' + + 'Beobachtungsspeicher wurde entfernt – letzteres ist genau das, was nach einem\n' + + 'Zurückspulen passieren müsste, damit der nächste Lauf den gewaschenen Stand als\n' + + 'Wahrheit übernimmt.\n\n' + + 'Ohne Bestätigung wird hier nichts als Ausgangspunkt akzeptiert. Bitte erst klären:\n' + + ` * Stimmt der aktuelle Kopf ${remoteKopf.slice(0, 12)} mit dem überein, was zuletzt\n` + + ' beglaubigt wurde? (Protokoll, Monitoring, zweite Kopie des Gegenbuchs)\n' + + ' * Gibt es refs/notary/seq-* auf dem Server? Dann lässt sich der Stand daraus\n' + + ' rekonstruieren, statt ihn zu glauben.\n\n' + + 'Erst wenn das geklärt ist: NOTARY_ADOPT_ACK=true setzen.', + ); + process.exit(4); + } + if (!remoteHatHistorie && process.env.NOTARY_GENESIS_ACK !== 'true') { + console.error( + 'Erstinbetriebnahme: Es gibt weder ein Wächter-Gedächtnis noch eine notarisierte\n' + + 'Historie. Das ist plausibel – aber die erste Baseline ist ein Vertrauensakt und\n' + + 'wird deshalb einmalig bestätigt:\n' + + ' NOTARY_GENESIS_ACK=true', + ); + process.exit(4); + } +} + if (letzteBeobachtung) { if (letzteBeobachtung.sha === remoteKopf) { ffBelegt = true; @@ -471,6 +562,14 @@ for (let i = 0; i < bisher.length; i++) { } } +if (hoechsterAnker && bisher.length < hoechsterAnker.seq) { + alarm( + `Die Checkpoint-Reihe ist kürzer als der Server beglaubigt hat: ` + + `verankert bis ${hoechsterAnker.seq}, vorhanden ${bisher.length}.\n` + + `Die vorhandenen Einträge sind korrekt signiert – es fehlen welche am Ende.`, + ); +} + if (Number.isFinite(MIN_SEQ) && bisher.length < MIN_SEQ) { alarm( `Die Checkpoint-Reihe ist kürzer als bekannt: erwartet mindestens ${MIN_SEQ}, ` + @@ -623,6 +722,19 @@ if (PUSH && !NUR_LOKAL) { ); process.exit(3); } + // Eigener, unveraenderlicher Ref je Checkpoint. Er ueberlebt einen + // Force-Push auf den Zweig und macht das Gedaechtnis wiederherstellbar, + // statt es an diesen Rechner zu binden. + const ankerRef = `refs/notary/seq-${eintrag.seq}`; + const a = gitStill('push', REMOTE, `HEAD:${ankerRef}`); + if (!a.ok) { + console.warn( + `HINWEIS: Anker-Ref ${ankerRef} konnte nicht gesetzt werden.\n` + + ` Der Checkpoint ist gepusht, aber ohne diesen Ref bleibt das Gedächtnis an\n` + + ` diesen Rechner gebunden – geht es verloren, ist der Stand nicht mehr aus dem\n` + + ` Server rekonstruierbar. Grund: ${a.out}`, + ); + } verankert = true; beobachtungMerken(); } else if (NUR_LOKAL) {