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 ## ✅ 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) - [x] **🚦 Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)** (2026-08-18)
- **R179-b der wichtigere Punkt:** Mein Rewind-Vorbehalt stand in der - **R179-b der wichtigere Punkt:** Mein Rewind-Vorbehalt stand in der
Ausgabe, der Exit blieb **0**. Meine eigene README sagt „fuer Cron gilt: 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 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 **`NOTARY_MIN_SEQ` ersetzt die serverseitige Sperre nicht.** Der Wert ist eine
*Untergrenze* und immer nur so frisch wie deine letzte Beobachtung. Wer *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 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) | | 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) |
| 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) | | 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) |
| 2 | **Befund** Widerspruch zwischen CRM und Gegenbuch, oder ungültige Signatur | | 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, 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. 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. 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. 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 ## Was das Skript erkennt
| Angriff | Erkennung | | 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 | | Einträge am Ende abgeschnitten | aktuelle höchste ID kleiner als die beglaubigte |
| Bestandssiegel verschwunden | vorher beglaubigt, jetzt nicht mehr vorhanden | | Bestandssiegel verschwunden | vorher beglaubigt, jetzt nicht mehr vorhanden |
| Gegenbuch selbst gekürzt | Lücke in der fortlaufenden Nummer | | 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 | | Gegenbuch lokal manipuliert | Arbeitsdatei weicht vom signierten Stand ab |
| Untergeschobener Commit | Commit ohne gültige Signatur in der Historie | | Untergeschobener Commit | Commit ohne gültige Signatur in der Historie |
| Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf | | 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). * fehl, wird die Datei zurueckgerollt (Pentest R175-03).
*/ */
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
import { resolve, basename } from 'node:path'; import { resolve, basename, join, dirname } from 'node:path';
import { homedir } from 'node:os';
const CRM_URL = process.env.CRM_URL; const CRM_URL = process.env.CRM_URL;
const CRM_TOKEN = process.env.CRM_TOKEN; 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 // ~168 hinterherhinkt jeder Rewind in dieses Fenster passiert lautlos, und
// der gewaschene Stand wird sogar ausdruecklich bestaetigt. // der gewaschene Stand wird sogar ausdruecklich bestaetigt.
const REWIND_GESCHUETZT = process.env.NOTARY_REWIND_PROTECTED === 'true'; 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') { if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschuetzt-ist') {
console.error( console.error(
'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' + '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, // 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 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.' +
@@ -228,7 +250,11 @@ const REWIND_VORBEHALT = () =>
// feststellbar“, dieselbe Bedeutung wie beim unerreichbaren Remote und // feststellbar“, dieselbe Bedeutung wie beim unerreichbaren Remote und
// bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. // bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
const abschliessendSicher = (anzahl) => 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); }; 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. // 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}).` : '.') + (letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.') +
OHNE_ANKER() + REWIND_VORBEHALT(), OHNE_ANKER() + REWIND_VORBEHALT(),
); );
beobachtungMerken();
process.exit(sicher ? 0 : 3); 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 = { const eintrag = {
seq: bisher.length + 1, seq: bisher.length + 1,
ts: aktuell.ts, ts: aktuell.ts,
@@ -495,19 +608,36 @@ if (SIGN) {
} }
if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert das Gegenbuch ist ungeschützt.'); if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert das Gegenbuch ist ungeschützt.');
let verankert = false;
if (PUSH && !NUR_LOKAL) { if (PUSH && !NUR_LOKAL) {
const p = gitStill('push', REMOTE, zweig); const p = gitStill('push', REMOTE, zweig);
if (!p.ok) { 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( 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( console.log(
`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).` + OHNE_ANKER() + `${verankert || NUR_LOKAL ? 'OK' : 'HINWEIS'}: Checkpoint ${eintrag.seq} ` +
REWIND_VORBEHALT() + `${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}`, `\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);