Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (R180)

"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 gekoppelt.

Fix: Jeder verankerte Checkpoint bekommt einen eigenen Ref refs/notary/seq-N.
Der ueberlebt einen Force-Push auf den Zweig - die hoechste je existierende
Nummer ist damit aus dem Server rekonstruierbar. Geprueft wird, ob der hoechste
verankerte Checkpoint noch im aktuellen Kopf enthalten ist und ob die Reihe
mindestens so lang ist wie verankert.

Laute Verlustbehandlung mit Diskriminator "traegt der Remote schon
Checkpoints?": keine Historie -> Genesis, einmalig NOTARY_GENESIS_ACK; Historie
vorhanden aber kein Gedaechtnis -> Anomalie, Code 4, keine stille Adoption,
erst nach NOTARY_ADOPT_ACK.

Verifiziert: Genesis ohne Bestaetigung -> Code 4; mit Bestaetigung Kette
aufgebaut samt refs/notary/seq-1..5; Rewind -> exit 2 auch nach Loeschen des
lokalen Speichers und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft
gruen); zusaetzlich Anker-Refs geloescht -> Code 4 statt stiller Uebernahme.

Dokumentiert: Die Anker-Refs muessen serverseitig ebenfalls vor Loeschen und
Ueberschreiben geschuetzt sein, sonst verschiebt sich das Problem eine Ebene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 09:10:02 +02:00
co-authored by Claude Opus 5
parent b6b6f7c0a7
commit 1a252c5059
3 changed files with 179 additions and 2 deletions
+34
View File
@@ -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
+31
View File
@@ -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,
+114 -2
View File
@@ -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) {