R179-b: Der Rewind-Vorbehalt stand in der Ausgabe, der Exit blieb 0. Die eigene README sagt "fuer Cron gilt: jeder Code ausser 0 gehoert gemeldet" - der Zustand "ich bin an dieser Stelle blind" erreichte die Ueberwachung also nie. Dieselbe Klasse wie R174, eine Ebene hoeher. Fix: Code 0 nur bei belegter Gewissheit (Rewind-Schutz zugesichert oder Mindesthoehe erfuellt), sonst Code 3 - bewusst nicht mit dem Manipulationsalarm 2 verschmolzen. R179-01: NOTARY_MIN_SEQ ist eine Untergrenze, kein Ist-Stand. Ein veralteter Wert liess einen Teil-Rewind darueber lautlos durch, und die blosse Praesenz einer Zahl blendete den Vorbehalt aus - MIN_SEQ=0 war ein Freibrief, ein Tippfehler wurde still verschluckt. Ein veralteter Anker erzeugte damit ein selbstbewussteres Ergebnis als gar keiner. Fix: Der Vorbehalt haengt allein an NOTARY_REWIND_PROTECTED=true und benennt bei gesetztem MIN_SEQ dessen Grenze; MIN_SEQ <= 0 oder unparsbar fuehrt zu exit 1 statt stiller Annahme. Verifiziert gegen den Pentest-Aufbau (10 Checkpoints, Rewind auf 7, DB passend gekuerzt, frischer Klon): nicht gesetzt -> exit 3 (vorher 0); 10 und 8 -> Alarm 2; 7 veraltet -> 0 mit Vorbehalt (vorher ohne); 0 und xyz -> exit 1 (vorher stilles 0); Rewind-Schutz zugesichert -> 0 ohne Vorbehalt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
8.2 KiB
Markdown
184 lines
8.2 KiB
Markdown
# Gegenbuch (externe Notarisierung)
|
||
|
||
Alle Schutzmechanismen im CRM selbst – Hash-Kette, Versionsgrenze,
|
||
Löschungs-Manifest, Bestandssiegel – liegen in **derselben Datenbank**, die sie
|
||
absichern sollen. Wer dort schreiben kann, sitzt am Ende immer schon auf der
|
||
Ebene, die den Beweis führt. Genau das hat der Pentest über mehrere Runden
|
||
Schicht für Schicht gezeigt.
|
||
|
||
Das Gegenbuch durchbricht das: Ein zweiter Rechner holt regelmäßig einen kurzen
|
||
Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und hängt ihn
|
||
signiert an ein privates Repository an.
|
||
|
||
## Die eine nicht verhandelbare Bedingung
|
||
|
||
**Der Signaturschlüssel und das Push-Recht dürfen nicht in den Deploy-Secrets
|
||
des CRM liegen.** Sonst ist der zweite Schlüssel im selben Zugriffsbereich wie
|
||
der erste – es sieht nach doppeltem Boden aus und ist keiner. Das CRM braucht
|
||
für dieses Verfahren **gar nichts** zu wissen: Es liefert nur einen lesbaren
|
||
Kontrollwert, der keine Geheimnisse enthält.
|
||
|
||
## Einrichten
|
||
|
||
Auf einem **anderen** Rechner als dem CRM-Server:
|
||
|
||
```bash
|
||
git clone git@host:dein/audit-gegenbuch.git
|
||
cd audit-gegenbuch
|
||
|
||
export CRM_URL=https://crm.example.de
|
||
export CRM_TOKEN=<Access-Token eines Benutzers mit audit:read>
|
||
|
||
node /pfad/zu/notary.mjs
|
||
```
|
||
|
||
Als Cronjob, stündlich:
|
||
|
||
```cron
|
||
0 * * * * cd /pfad/zum/gegenbuch && CRM_URL=https://crm.example.de \
|
||
CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1
|
||
```
|
||
|
||
**Force-Push serverseitig sperren – das ist Pflicht, nicht Empfehlung.**
|
||
|
||
Konkret muss der Server **non-fast-forward-Pushes verbieten** (Rewind-Schutz),
|
||
nicht nur „irgendeine" Branch-Protection. Der Grund ist nicht offensichtlich:
|
||
|
||
Das Skript prüft jede Signatur. Aber ein Angreifer mit Force-Push-Recht muss
|
||
gar nichts fälschen – er kann die Reihe schlicht auf einen **älteren, echt
|
||
signierten Stand zurückspulen** und die Datenbank passend kürzen. Alle
|
||
Signaturen bleiben gültig, der Fingerabdruck stimmt, die Nummerierung ist
|
||
lückenlos. Kryptographisch ist daran nichts auszusetzen; es fehlt nur das Ende.
|
||
|
||
Ein Notar-Rechner, der die höhere Nummer noch lokal kennt, merkt es. Ein
|
||
**frischer Klon merkt es nicht** – und das ist ausgerechnet der Auditoren-Fall.
|
||
|
||
Deshalb zusätzlich:
|
||
|
||
```bash
|
||
# Die zuletzt bekannte Nummer mitgeben – dann fällt ein Rewind auch ohne
|
||
# lokalen Zustand auf. Das Skript nennt sie am Ende jedes Laufs.
|
||
NOTARY_MIN_SEQ=42 node notary.mjs --check
|
||
```
|
||
|
||
**`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
|
||
um rund 168 hinterherhinkt – ein Zurückspulen in dieses Fenster bliebe
|
||
unbemerkt und der gewaschene Stand würde sogar ausdrücklich bestätigt.
|
||
|
||
Ist der Rewind-Schutz auf dem Server tatsächlich eingerichtet, bestätige das:
|
||
|
||
```bash
|
||
NOTARY_REWIND_PROTECTED=true
|
||
```
|
||
|
||
**Nur diese Zusicherung** blendet den Vorbehalt aus – nicht die bloße Angabe
|
||
einer Zahl. Solange sie fehlt, endet jeder Lauf mit **Code 3** statt 0:
|
||
„nicht abschließend feststellbar". Das ist Absicht, denn ein Cronjob liest den
|
||
Rückgabecode, nicht den Fließtext – ein Hinweis, der nur in der Ausgabe steht,
|
||
erreicht die Überwachung nie.
|
||
|
||
## Der entscheidende Punkt: es wird tatsächlich geprüft
|
||
|
||
Die erste Fassung dieses Skripts hat signiert – aber **nie eine Signatur
|
||
geprüft**. Es las seine Wahrheit aus der lokalen Arbeitsdatei. Wer den
|
||
Notar-Rechner beschreiben konnte, hat ihm damit beliebige „beglaubigte"
|
||
Zeilen untergeschoben und einen abgeschnittenen Datenbestand als neue Wahrheit
|
||
festgeschrieben, ohne Alarm. Signieren ohne prüfenden Leser ist wertlos.
|
||
|
||
Deshalb gilt jetzt:
|
||
|
||
- Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der Remote-Kopf),
|
||
nicht die Arbeitsdatei
|
||
- **Jeder** Commit, der das Gegenbuch ändert, muss eine gültige Signatur tragen
|
||
- Weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen
|
||
- Geschrieben wird erst nach erfolgreichem, signiertem Commit – scheitert er,
|
||
wird die Datei zurückgerollt
|
||
|
||
## Voraussetzungen, die wirklich Voraussetzungen sind
|
||
|
||
- **`allowed_signers` muss den Notar-Schlüssel enthalten.** Das Skript
|
||
akzeptiert ausschließlich Signaturen, die git als `G` bewertet – bei
|
||
SSH-Signaturen heißt das wörtlich „Schlüssel steht in `allowed_signers`".
|
||
Ein fremder, selbst erzeugter Schlüssel liefert `U` und wird abgelehnt.
|
||
*(Bei GPG statt SSH liefert auch ein legitimer Schlüssel ohne Ownertrust ein
|
||
`U` – dann Ownertrust setzen, nicht `U` wieder zulassen.)*
|
||
- **Ein Remote muss erreichbar sein.** Ist er es nicht, bricht das Skript mit
|
||
Code 3 ab, statt dem lokalen Stand zu glauben. Ein Gegenbuch, dessen
|
||
beglaubigter Stand sich nicht feststellen lässt, gibt kein grünes Licht.
|
||
- **Genau ein Schlüssel zählt, nicht die ganze Liste.** `allowed_signers` kann
|
||
mehrere Principals enthalten – ein Kollege, ein Alt-Schlüssel, eine global
|
||
geteilte Datei. Jeder davon würde sonst als „gültig" durchgehen und könnte
|
||
das Gegenbuch umschreiben. Deshalb prüft das Skript zusätzlich den
|
||
**Fingerabdruck** des Signierschlüssels.
|
||
|
||
Er wird automatisch aus `user.signingkey` abgeleitet. Lässt er sich nicht
|
||
bestimmen, bricht das Skript ab – statt stillschweigend die ganze Liste zu
|
||
akzeptieren. Explizit setzen geht auch:
|
||
|
||
```bash
|
||
export NOTARY_SIGNER_FINGERPRINT=$(git log -1 --format=%GF)
|
||
```
|
||
|
||
Wichtig: exakt der `%GF`-Wert (`SHA256:…`), nicht die vollständige Ausgabe
|
||
von `ssh-keygen -lf`.
|
||
|
||
## Rückgabecodes
|
||
|
||
| Code | Bedeutung |
|
||
|---|---|
|
||
| 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 |
|
||
|
||
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.
|
||
|
||
## Prüfmodus für Auditoren
|
||
|
||
```bash
|
||
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.
|
||
|
||
## Was das Skript erkennt
|
||
|
||
| Angriff | Erkennung |
|
||
|---|---|
|
||
| Beglaubigter Eintrag nachträglich verändert | Hash an der beglaubigten Position stimmt nicht mehr |
|
||
| 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 |
|
||
| 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 |
|
||
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
|
||
|
||
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
|
||
nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit
|
||
festgeschrieben. Für Cron heißt das: Exit-Code überwachen, sonst geht der Alarm
|
||
im Log unter.
|
||
|
||
## Was es nicht leistet – ehrlich
|
||
|
||
- **Restfenster:** Einträge zwischen dem letzten Kontrollwert und jetzt sind
|
||
noch nicht beglaubigt und am Ende fälsch- oder kürzbar. Häufigerer Lauf
|
||
verkleinert das Fenster, beseitigt es aber nicht. Das gilt für jedes
|
||
periodische Verfahren dieser Art.
|
||
- **Stiller Ausfall:** Läuft der Cronjob nicht mehr, entsteht keine Warnung im
|
||
CRM – dort ist ja nichts konfiguriert. Der Ausfall muss auf dem
|
||
Gegenbuch-Rechner überwacht werden (z. B. Alarm, wenn der jüngste Commit
|
||
älter als zwei Intervalle ist).
|
||
- **`NOTARY_SIGN=false`** existiert nur für Tests ohne hinterlegten Schlüssel
|
||
und verlangt zusätzlich `NOTARY_INSECURE_ACK=…`. Der Reflex „Cron hakt, also
|
||
Signatur abschalten" soll bewusst nicht ausreichen – er würde eine
|
||
ungeschützte Kette festschreiben.
|
||
- **Der Remote-Server muss Force-Push verhindern.** Das Skript prüft die
|
||
Signaturen, aber wenn jemand die Historie am Server überschreiben kann,
|
||
ersetzt er auch die signierten Commits durch eigene. Branch-Protection ist
|
||
Teil des Schutzes, nicht Zubehör.
|