Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (R179 a/b)

(a) Probe-Push verworfen. Er haette nur den geprobten Ref beurteilt, die eigene
Push-Identitaet gemessen statt die des Angreifers (Bypass-Rechte fuer Admins
gehen genau dann auseinander, wenn es zaehlt), nur einen Zeitpunkt abgedeckt -
und einen zerstoerungsfreien Force-Push gibt es nicht: die bestaetigende
Beobachtung waere derselbe Vorgang wie der Schaden.

Stattdessen ein Fast-Forward-Waechter: Der beobachtete Remote-Kopf wird
ausserhalb des Klons festgehalten; beim naechsten Lauf muss der neue Kopf ein
Nachfahre des alten sein. Das erkennt das Ereignis statt die Regel abzufragen
und wirkt unabhaengig von serverseitigem Schutz. Ein belegter Fast-Forward
gilt als Nachweis und blendet den Rewind-Vorbehalt aus.

(b) Code 3 als Tor vor dem Anhaengen statt als Status danach: Der Schreiblauf
signiert mit dem neuen Checkpoint zugleich ueber den Bestand darunter - ist die
Basis ungeklaert, waere das Anhaengen selbst das Waschmittel. Grundlage nicht
feststellbar -> nichts anhaengen, exit 3. Erster Lauf -> Basislinie, ehrlich
gemeldet, exit 3. Anhaengen geklappt, Push gescheitert -> exit 3 mit "erstellt,
aber NICHT verankert". 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
(bisher stilles Gruen); kaputtes Push-Ziel -> "erstellt, aber nicht verankert"
exit 3, Folgelauf haelt den ungepushten Commit fail-closed an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:29:20 +02:00
co-authored by Claude Opus 5
parent 8d1ffc0df8
commit b6b6f7c0a7
3 changed files with 207 additions and 10 deletions
+30
View File
@@ -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:
+38 -1
View File
@@ -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 |
+139 -9
View File
@@ -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);