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 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 10:59:07 +02:00
co-authored by Claude Opus 5
parent 1a252c5059
commit a954f0f736
3 changed files with 114 additions and 12 deletions
+35
View File
@@ -97,6 +97,41 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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 35 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) - [x] **🧠 Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (Pentest R180)** (2026-08-18)
- Sein Durchrechnen war zwingend: „erste Beobachtung → exit 3“ galt **genau - Sein Durchrechnen war zwingend: „erste Beobachtung → exit 3“ galt **genau
einen Lauf**. Danach baselinete der Waechter auf den AKTUELLEN Kopf nach einen Lauf**. Danach baselinete der Waechter auf den AKTUELLEN Kopf nach
+16 -2
View File
@@ -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, den ein Angreifer gezielt herstellen kann, ist kein sicherer
Standardzustand. Standardzustand.
> **Diese Refs gehören ebenfalls serverseitig geschützt** (kein Löschen, kein > ### ⚠ Ohne serverseitigen Schutz dieser Refs bringt das Verfahren nichts
> Überschreiben). Sonst verschiebt sich das Problem nur eine Ebene weiter. >
> `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 ### Wenn das Gedächtnis trotzdem fehlt
+63 -10
View File
@@ -228,7 +228,7 @@ const OHNE_ANKER = () =>
// signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung, // signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung,
// sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist. // sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist.
const REWIND_VORBEHALT = () => const REWIND_VORBEHALT = () =>
REWIND_GESCHUETZT || ankerBelegt || ffBelegt REWIND_GESCHUETZT || ffBelegt
? '' ? ''
: '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' + : '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' +
'lässt sich hier nicht ausschließen.' + 'lässt sich hier nicht ausschließen.' +
@@ -251,9 +251,14 @@ const REWIND_VORBEHALT = () =>
// bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. // bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
const abschliessendSicher = (anzahl) => const abschliessendSicher = (anzahl) =>
REWIND_GESCHUETZT || REWIND_GESCHUETZT ||
// Verankerte Refs sind das belastbarste Signal: sie liegen im Substrat und // BEWUSST NICHT `ankerBelegt` (Pentest R180-01): Die Anker-Refs zeigen nur
// ueberleben einen Force-Push auf den Zweig. // den HOECHSTEN NOCH VORHANDENEN Stand nicht den hoechsten je erreichten.
ankerBelegt || // 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 // Belegte Fast-Forward-Kette seit der ersten Beobachtung: das ist ein
// Nachweis, kein Versprechen deshalb zaehlt er (R179 (a)). // Nachweis, kein Versprechen deshalb zaehlt er (R179 (a)).
ffBelegt || ffBelegt ||
@@ -407,7 +412,13 @@ let ffBelegt = false;
// Deshalb: kein stilles Neu-Baselinen. Eine Baseline zu setzen heisst „ich // Deshalb: kein stilles Neu-Baselinen. Eine Baseline zu setzen heisst „ich
// verbuerge mich, DAS ist der wahre Kopf“ dieser Akt gehoert bestaetigt. // 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; const remoteHatHistorie = gitStill('show', `${ref}:${LEDGER_NAME}`).ok;
if (remoteHatHistorie && process.env.NOTARY_ADOPT_ACK !== 'true') { if (remoteHatHistorie && process.env.NOTARY_ADOPT_ACK !== 'true') {
console.error( 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. // 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) { if (hoechsterAnker && bisher.length < hoechsterAnker.seq) {
alarm( alarm(
`Die Checkpoint-Reihe ist kürzer als der Server beglaubigt hat: ` + `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 ankerRef = `refs/notary/seq-${eintrag.seq}`;
const a = gitStill('push', REMOTE, `HEAD:${ankerRef}`); const a = gitStill('push', REMOTE, `HEAD:${ankerRef}`);
if (!a.ok) { if (!a.ok) {
console.warn( // Kein blosser Hinweis mehr (R180-02): Der Anker IST die tragende
`HINWEIS: Anker-Ref ${ankerRef} konnte nicht gesetzt werden.\n` + // Eigenschaft. Fehlt er, ist der Checkpoint zwar im Zweig, aber der
` Der Checkpoint ist gepusht, aber ohne diesen Ref bleibt das Gedächtnis an\n` + // Hoechststand nicht mehr aus dem Server rekonstruierbar und der
` diesen Rechner gebunden geht es verloren, ist der Stand nicht mehr aus dem\n` + // naechste Auditor saehe einen um eins zu niedrigen Stand als vollstaendig.
` Server rekonstruierbar. Grund: ${a.out}`, 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; verankert = true;
beobachtungMerken(); beobachtungMerken();