From a954f0f736faec9c2a13cb5621d0da591bfa7fe9 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 22 Aug 2026 10:59:07 +0200 Subject: [PATCH] Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (R180-01/-02) R180-01: Der Code nahm den hoechsten noch vorhandenen refs/notary/seq-* als "hoechsten je". Loescht ein Angreifer nur die oberen Anker und laesst einen niedrigeren stehen, senkt er den Vergleichswert selbst - ein frischer Auditoren-Klon meldete OK, exit 0 auf gewaschenem Stand. Perverser Gradient: Wer alle Refs loeschte, flog auf (Code 4); wer weniger loeschte, kam durch, weil ankerBelegt sowohl die Gewissheit begruendete als auch den Code-4-Diskriminator kurzschloss. Fix: Anker begruenden keine Gewissheit mehr. Sie koennen ein Zurueckspulen widerlegen, aber nie Unversehrtheit belegen. Der Code-4-Diskriminator haengt nicht mehr an ihnen und greift nur im Schreiblauf. R180-02: Der Anker-Push-Fehlschlag war nur eine Warnung mit exit 0 - ausgerechnet bei der tragenden Eigenschaft. Der Normalbetrieb senkte damit den Hoechststand still um eins. Fix: exit 3 bei Fehlschlag, und jeder Lauf zieht fehlende Anker nach, bevor er etwas als OK meldet. Verifiziert: Teil-Loeschung mit frischem Auditoren-Klon -> exit 3 mit Vorbehalt statt exit 0; Anker-Push per Hook abgelehnt -> exit 3 statt Hinweis; Folgelauf traegt den fehlenden Anker nach. README: Der Schutz von refs/notary/* gegen Loeschen und Ueberschreiben ist von der Fussnote zur tragenden Voraussetzung erhoben. Co-Authored-By: Claude Opus 5 --- docs/todo.md | 35 +++++++++++++++++ tools/audit-notary/README.md | 18 ++++++++- tools/audit-notary/notary.mjs | 73 ++++++++++++++++++++++++++++++----- 3 files changed, 114 insertions(+), 12 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 4bb5c245..6bc4ff8b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,41 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🛑 Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (Pentest R180-01/-02)** (2026-08-18) + - **R180-01 (MEDIUM)** – Genau die Unsicherheit, die ich selbst benannt + hatte, bestaetigt: Der Code nahm den **hoechsten noch vorhandenen** + `refs/notary/seq-*` als „hoechsten je“. Loescht ein Angreifer nur die + OBEREN Anker und laesst einen niedrigeren stehen, senkt er den + Vergleichswert selbst – ein frischer Auditoren-Klon meldete `OK`, exit 0 + auf gewaschenem Stand. + Perverser Gradient: Wer ALLE Refs loeschte, flog auf (Code 4); wer weniger + loeschte, kam durch – weil `ankerBelegt` sowohl die Gewissheit begruendete + als auch den Code-4-Diskriminator kurzschloss. + Fix: **Anker begruenden keine Gewissheit mehr.** Sie koennen ein + Zurueckspulen *widerlegen* (hoher Anker nicht mehr im Kopf → Alarm), aber + nie Unversehrtheit *belegen*. Der Code-4-Diskriminator haengt nicht mehr an + ihnen und greift nur noch im Schreiblauf (ein Auditor hatte nie ein + Gedaechtnis, ihm Code 4 vorzuwerfen waere Laerm). + - **R180-02 (LOW→MED)** – Der Anker-Push-Fehlschlag war nur eine Warnung mit + exit 0, ausgerechnet bei der tragenden Eigenschaft: `main` stand auf dem + neuen Checkpoint, der Anker fehlte, niemand holte es nach. Der + **Normalbetrieb** senkte damit den Hoechststand still um eins und riss das + R180-01-Fenster ohne Angriff auf. + Fix: Anker-Push-Fehlschlag → **exit 3**; und jeder Lauf **zieht fehlende + Anker nach**, bevor er irgendetwas als OK meldet (Zuordnung ueber die + n-te Aenderung am Gegenbuch). + - Verifiziert: Teil-Loeschung (main auf seq-2, Anker 3–5 weg, DB gekuerzt), + frischer Auditoren-Klon → **exit 3 mit Vorbehalt statt exit 0** (das falsche + Gruen ist weg; erkannt wird es in-repo weiterhin nicht, das geht ohne + Ref-Schutz nicht); Anker-Push per Hook abgelehnt → **exit 3** statt + Hinweis; Folgelauf → „Fehlende Anker nachgetragen: 1“, Anker wieder + vollstaendig. + - **README:** Der Schutz von `refs/notary/*` gegen Loeschen und Ueberschreiben + ist von der Fussnote zur **tragenden Voraussetzung** erhoben – mit der + Begruendung, warum Anker nichts belegen koennen, und dem ausdruecklichen + Hinweis, dass ein `--check` aus frischem Klon eine Anker-Loeschung nicht + erkennt. + - [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 diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index f2643b4b..b153bdf4 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -91,8 +91,22 @@ 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. +> ### ⚠ Ohne serverseitigen Schutz dieser Refs bringt das Verfahren nichts +> +> `refs/notary/*` muss auf dem Server **gegen Löschen und Überschreiben** +> gesperrt sein. Das ist keine Empfehlung, sondern die tragende Voraussetzung – +> genauso wie die Rewind-Sperre auf `main`. +> +> Der Grund ist eine unangenehme Asymmetrie: Das Skript kennt nur den +> **höchsten noch vorhandenen** Anker, nicht den höchsten je erzeugten. Wer +> *alle* Anker löscht, fliegt auf (Code 4). Wer nur die **obersten** löscht und +> einen niedrigeren stehen lässt, senkt damit den Vergleichswert selbst – und +> in-repo ist das von „gab es nie" nicht zu unterscheiden. +> +> Deshalb begründen Anker **keine** Unversehrtheit. Sie können ein +> Zurückspulen *widerlegen* (wenn ein hoher Anker nicht mehr im Kopf steckt), +> aber nie belegen, dass nichts fehlt. Ein `--check` aus frischem Klon erkennt +> eine Anker-Löschung **nicht** – es meldet dann Code 3 mit Vorbehalt, nicht 0. ### Wenn das Gedächtnis trotzdem fehlt diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index 2b6148db..640274d9 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 || ankerBelegt || ffBelegt + REWIND_GESCHUETZT || ffBelegt ? '' : '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' + 'lässt sich hier nicht ausschließen.' + @@ -251,9 +251,14 @@ 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 || + // BEWUSST NICHT `ankerBelegt` (Pentest R180-01): Die Anker-Refs zeigen nur + // den HOECHSTEN NOCH VORHANDENEN Stand – nicht den hoechsten je erreichten. + // Wer die obersten Refs loescht, senkt damit den Vergleichswert selbst; ein + // niedrigerer Anker bleibt stehen und sah bisher wie Bestaetigung aus. + // Perverser Gradient: Wer ALLE Refs loescht, flog auf (Code 4); wer nur die + // oberen loeschte, kam durch. Anker koennen ein Zurueckspulen also WIDERLEGEN + // (siehe Alarm oben), aber nie Unversehrtheit BELEGEN. + ffBelegt || // Belegte Fast-Forward-Kette seit der ersten Beobachtung: das ist ein // Nachweis, kein Versprechen – deshalb zaehlt er (R179 (a)). ffBelegt || @@ -407,7 +412,13 @@ let ffBelegt = false; // 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) { +// Nur der SCHREIBLAUF setzt eine Baseline – nur dort ist die Sperre noetig. +// Ein Auditor mit frischem Klon hatte nie ein Gedaechtnis; ihm Code 4 +// vorzuwerfen waere Laerm. Er bekommt stattdessen exit 3 mit Vorbehalt. +// +// Das Kurzschliessen ueber `ankerBelegt` ist entfallen (R180-01): ein stehen +// gelassener niedriger Anker durfte die Sperre nicht aufheben. +if (!letzteBeobachtung && !NUR_PRUEFEN) { const remoteHatHistorie = gitStill('show', `${ref}:${LEDGER_NAME}`).ok; if (remoteHatHistorie && process.env.NOTARY_ADOPT_ACK !== 'true') { console.error( @@ -479,6 +490,40 @@ const beobachtungMerken = () => { } }; +// Fehlende Anker nachziehen (Pentest R180-02). +// +// Scheitert der Anker-Push, stand `main` schon auf dem neuen Checkpoint, +// waehrend der Anker fehlte – und niemand holte das nach. Der Normalbetrieb +// senkte damit den Substrat-Hoechststand still um eins und riss genau das +// Fenster auf, das R180-01 ausnutzt. Deshalb wird vor allem anderen +// nachgezogen, was fehlt. +function ankerNachziehen(bisherLaenge) { + if (NUR_LOKAL || !PUSH) return; + const vorhanden = new Set(ankerRefs.map((a) => a.seq)); + const fehlend = []; + for (let n = 1; n <= bisherLaenge; n++) if (!vorhanden.has(n)) fehlend.push(n); + if (!fehlend.length) return; + + // Die n-te Aenderung am Gegenbuch entspricht Checkpoint n. + const commits = gitStill('log', '--reverse', '--format=%H', ref, '--', LEDGER_NAME); + const liste = commits.ok ? commits.out.split('\n').filter(Boolean) : []; + let nachgezogen = 0; + for (const n of fehlend) { + const sha = liste[n - 1]; + if (!sha) continue; + if (gitStill('push', REMOTE, `${sha}:refs/notary/seq-${n}`).ok) nachgezogen++; + } + if (nachgezogen) console.log(`Fehlende Anker nachgetragen: ${nachgezogen}`); + if (nachgezogen < fehlend.length) { + console.error( + `${fehlend.length - nachgezogen} Anker-Ref(s) fehlen und ließen sich nicht nachtragen.\n` + + 'Ohne sie ist der beglaubigte Höchststand nicht aus dem Server rekonstruierbar –\n' + + 'genau die Eigenschaft, auf der dieser Schutz beruht.', + ); + process.exit(3); + } +} + // --------------------------------------------------------------------------- // 2) Signaturen der GESAMTEN Historie pruefen – das war der fehlende Leser. // --------------------------------------------------------------------------- @@ -562,6 +607,8 @@ for (let i = 0; i < bisher.length; i++) { } } +ankerNachziehen(bisher.length); + if (hoechsterAnker && bisher.length < hoechsterAnker.seq) { alarm( `Die Checkpoint-Reihe ist kürzer als der Server beglaubigt hat: ` + @@ -728,12 +775,18 @@ if (PUSH && !NUR_LOKAL) { 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}`, + // Kein blosser Hinweis mehr (R180-02): Der Anker IST die tragende + // Eigenschaft. Fehlt er, ist der Checkpoint zwar im Zweig, aber der + // Hoechststand nicht mehr aus dem Server rekonstruierbar – und der + // naechste Auditor saehe einen um eins zu niedrigen Stand als vollstaendig. + console.error( + `Checkpoint ${eintrag.seq} ist gepusht, aber der Anker ${ankerRef} konnte NICHT ` + + `gesetzt werden.\n` + + `Damit ist der beglaubigte Höchststand nicht mehr aus dem Server rekonstruierbar. ` + + `Der nächste Lauf versucht, ihn nachzutragen – bis dahin gilt der Stand als nicht ` + + `vollständig verankert.\n git-Meldung: ${a.out}`, ); + process.exit(3); } verankert = true; beobachtungMerken();