"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>
252 lines
12 KiB
Markdown
252 lines
12 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
|
||
```
|
||
|
||
## 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.
|
||
|
||
### 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
|
||
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
|
||
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 |
|
||
| 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,
|
||
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.
|
||
|
||
## 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 |
|
||
|---|---|
|
||
| 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 |
|
||
| **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 |
|
||
| 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.
|