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:
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user