Files
opencrm/tools/audit-notary/README.md
T
duffyduckandClaude Opus 5 773033936d Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (R181)
R181-01: Der Pruefmodus sagte zu, nichts zu veraendern und kein Schreibrecht zu
brauchen - und pushte trotzdem, weil ankerNachziehen() in jedem Modus lief. Ein
read-only Audit mutierte damit still das geteilte Substrat.

R181-02: Ein Widerspruch zwischen den eigenen Fixes. R180-01 erhebt die
Serversperre auf refs/notary/* zur tragenden Pflicht, R180-02 verlangt dort
Schreibrecht zur Selbstheilung. Sobald je ein Anker fehlte, bekam jeder
read-only pruefende Auditor dauerhaft einen Fehler auf einer voellig gueltigen
Kette, den er nicht beheben konnte. Fix: Reparieren nur im Notar-Schreiblauf,
im Pruefmodus wird der fehlende Anker gemeldet.

Eigener Rueckgabecode 5: "Anker unvollstaendig" ist nicht "nicht feststellbar" -
die Kette ist gueltig, nur das Substrat-Gedaechtnis unvollstaendig, ein
benannter reparierbarer Defekt. Dieselbe Trennung wie bei Genesis/Adoption.

Empirisch beantwortet: Die Notar-Identitaet laesst sich eng auf das Anlegen von
refs/notary/* beschraenken, ohne Loeschen oder Ueberschreiben - serverseitig
unterscheidbar an der Null-OID. Mit pre-receive-Hook verifiziert: Backfill
greift, Loeschen und Force-Overwrite bleiben abgewiesen. Hook als Beispiel in
der README.

Verifiziert: read-only --check mit fehlendem Anker -> Code 5, nichts gepusht;
Notar-Schreiblauf unter derselben ACL -> Anker nachgetragen, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:56:36 +02:00

286 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
> ### ⚠ Ohne serverseitigen Schutz dieser Refs bringt das Verfahren nichts
>
> `refs/notary/*` muss auf dem Server **gegen Löschen und Überschreiben**
> gesperrt sein. Das ist keine Empfehlung, sondern die tragende Voraussetzung
> genauso wie die Rewind-Sperre auf `main`.
>
> Der Grund ist eine unangenehme Asymmetrie: Das Skript kennt nur den
> **höchsten noch vorhandenen** Anker, nicht den höchsten je erzeugten. Wer
> *alle* Anker löscht, fliegt auf (Code 4). Wer nur die **obersten** löscht und
> einen niedrigeren stehen lässt, senkt damit den Vergleichswert selbst und
> in-repo ist das von „gab es nie" nicht zu unterscheiden.
>
> **Die Notar-Identität braucht dort eine eng gefasste Ausnahme:** Sie muss
> `refs/notary/*` **anlegen** dürfen, aber ausdrücklich **nicht löschen oder
> überschreiben**. Sonst hebelt eine kompromittierte Notar-Identität genau den
> Schutz aus, um den es hier geht. Server-seitig ist das unterscheidbar ein
> `pre-receive`-Hook sieht beim Anlegen die Null-OID als alten Wert:
>
> ```sh
> # refs/notary/*: nur anlegen, niemals ändern oder löschen
> case "$ref" in refs/notary/*)
> [ "$old" = "0000000000000000000000000000000000000000" ] || exit 1 ;;
> esac
> ```
>
> Deshalb begründen Anker **keine** Unversehrtheit. Sie können ein
> Zurückspulen *widerlegen* (wenn ein hoher Anker nicht mehr im Kopf steckt),
> aber nie belegen, dass nichts fehlt. Ein `--check` aus frischem Klon erkennt
> eine Anker-Löschung **nicht** es meldet dann Code 3 mit Vorbehalt, nicht 0.
### 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 |
| 5 | **Anker unvollständig** die Kette ist gültig, aber `refs/notary/seq-N` fehlt. Reparierbar durch einen Notar-Schreiblauf |
| 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, **verändert nichts** und braucht kein Schreibrecht
auch dann nicht, wenn dabei ein fehlender Anker auffällt. Der wird gemeldet
(Code 5), nicht repariert; Reparieren ist Sache des Notar-Laufs.
Das ist kein Detail: Die Serversperre für `refs/notary/*` lehnt Schreibzugriffe
ja gerade ab. Würde der Prüfmodus dort nachtragen wollen, bekäme jeder
read-only prüfende Auditor dauerhaft einen Fehler auf einer völlig gültigen
Kette und könnte ihn nicht beheben.
## 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.