Files
opencrm/tools/audit-notary/README.md
T
duffyduckandClaude Opus 5 3a50a40ad5 Versiegeln auffindbar machen: Titel und Inhaltsverzeichnis
Der Abschnitt war vollstaendig, aber nicht zu finden. Er hiess "Wenn der
erste Lauf exit=2 meldet" - also nach dem Symptom benannt, nicht nach der
Aufgabe - und stand in Zeile 89 einer 706-Zeilen-Datei ohne
Inhaltsverzeichnis. Wer "wie versiegle ich" suchte, fand ihn nicht; genau
das ist dem Betreiber passiert, obwohl der Text bei ihm lag.

Jetzt "Den Altbestand versiegeln (einmalig, im CRM)", dazu ein
Inhaltsverzeichnis nach Anlass gegliedert: einrichten, im Betrieb, wenn
Alarm kommt, zum Nachlesen. Mit einem ausdruecklichen Hinweis, dass der
Versiegelungs-Abschnitt eigenstaendig ist und auch ohne Gegenbuch gilt.

Der Verweis im Haupt-README zeigt jetzt direkt auf den Anker statt den
Abschnittstitel zu zitieren - so laeuft er beim naechsten Umbenennen
nicht ins Leere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:27:49 +02:00

738 lines
34 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 schreibt
ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
widerspricht das dem Gegenbuch.
## Was du hier suchst
**Einrichten**
[Betriebsart wählen](#zwei-betriebsarten--erst-hier-entscheiden) ·
[mit Docker](#einrichten-mit-docker-empfohlen) ·
[erster Start](#erster-start--drei-schritte) ·
[Zugangskonto im CRM](#zugang-einrichten-das-brauchst-du-vorher) ·
[ohne Docker](#einrichten-ohne-docker)
**Im Betrieb**
[**Altbestand versiegeln**](#den-altbestand-versiegeln-einmalig-im-crm) ·
[Rückgabecodes](#rückgabecodes) ·
[Überwachung](#überwachung) ·
[wo die Daten liegen](#wo-die-daten-liegen)
**Wenn Alarm kommt**
[Siegelwurzel hat gewechselt](#wenn-sich-die-siegelwurzel-ändert-alarm-und-warum) ·
[Kette wurde neu berechnet](#wenn-die-kette-neu-berechnet-wurde-ebenfalls-alarm) ·
[Zurückspulen erkannt](#der-rewind-wächter) ·
[Gedächtnis fehlt](#wenn-das-gedächtnis-trotzdem-fehlt)
**Zum Nachlesen**
[was erkannt wird](#was-das-skript-erkennt) ·
[was nicht ehrlich](#was-es-nicht-leistet--ehrlich) ·
[Prüfmodus für Auditoren](#prüfmodus-für-auditoren)
> **Nur schnell versiegeln?** Der Abschnitt
> [Altbestand versiegeln](#den-altbestand-versiegeln-einmalig-im-crm) ist
> eigenständig und gilt **auch ohne Gegenbuch** es ist ein Vorgang im CRM,
> nicht hier.
## Zwei Betriebsarten erst hier entscheiden
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|---|---|---|
| Das Buch liegt | auf dem Gegenbuch-Rechner | zusätzlich auf einem dritten Server |
| Aufwand | Docker starten, fertig | privates Git-Repo, Schlüssel, Server-Regeln |
| Schützt gegen | jemand verändert Daten **im CRM** | zusätzlich: jemand übernimmt den **Gegenbuch-Rechner** |
**Für die allermeisten Installationen ist „lokal" die richtige Wahl.** Der
Schutz, um den es geht nachträgliche Änderungen im CRM auffliegen zu lassen
steht damit vollständig. Die zweite Variante deckt einen Angreifer ab, der
zusätzlich den Gegenbuch-Rechner übernimmt; sie kostet spürbar mehr Einrichtung
und laufende Aufmerksamkeit.
Diese Anleitung beschreibt zuerst den lokalen Betrieb. Alles zur zweiten
Variante steht gesammelt unter **„Zusätzliche Härtung"** weiter unten wer
lokal betreibt, kann diesen ganzen Teil überspringen.
## 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 mit Docker (empfohlen)
Auf dem **Gegenbuch-Rechner** nicht auf dem CRM-Server:
```bash
git clone <dieses Repository> opencrm
cd opencrm/tools/audit-notary
cp .env.example .env
# .env ausfüllen: CRM-Adresse und Token eintragen
docker compose up -d
```
### Erster Start drei Schritte
Beim allerersten Lauf meldet der Container `exit=4` und verlangt eine
Bestätigung. Das ist Absicht: Der erste Eintrag legt fest, was als
Ausgangszustand gilt.
```bash
# 1. In der .env freigeben
STAGING_GENESIS_ACK=true
# 2. Neu starten (kein --build nötig, nur die Umgebung ändert sich)
docker compose up -d && docker compose logs -f
# Erwartet: "OK: Checkpoint 1 erstellt" und exit=3
# exit=3 ist hier richtig beim ersten Mal gibt es nichts zu vergleichen.
# 3. Wieder leeren und erneut starten
STAGING_GENESIS_ACK=
docker compose up -d
```
Ab dem nächsten Durchlauf steht dort `exit=0 (in Ordnung)`.
**Warum Schritt 3 wichtig ist:** Bleibt die Zeile auf `true`, würde der
Container nach einem Verlust des Datenverzeichnisses stillschweigend eine neue
Grundlage setzen, statt zu fragen genau davor schützt die Abfrage.
Für Produktion später dasselbe mit `PROD_GENESIS_ACK`. Schnell nachsehen ohne
Logs: `cat data/staging/status.txt`.
Beim ersten Start einmalig `PROD_GENESIS_ACK=true` setzen (und danach wieder
leeren) die erste Eintragung legt fest, was als Ausgangszustand gilt, und das
soll nicht versehentlich passieren.
**Zwei Bücher auf einer Maschine** sind vorgesehen: `prod` und `staging` sind
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
### Den Altbestand versiegeln (einmalig, im CRM)
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** Einträge
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
ist, meldet die Prüfung `valid: false`, das Gegenbuch schlägt zu Recht Alarm
und, wichtig: **es beglaubigt so lange gar nichts.** Es bricht vor dem Anhängen
ab, weil ein Checkpoint über einen ungeklärten Zustand diesen mitbeglaubigen
würde. Ein unversiegeltes CRM ist also nicht „bewacht mit Warnung", sondern
unbewacht.
Das Siegeln ist **einmalig** und passiert im CRM, nicht hier.
#### Wer darf das
Das Recht `audit:admin`. Das bekommt man über den Haken **„Audit-Betrieb"** in
der Benutzerverwaltung **nicht** über die Admin-Rolle (die hat bewusst keine
Audit-Rechte) und **nicht** über den DSGVO-Haken (der darf lesen, nicht
eingreifen). Das Gegenbuch-Dienstkonto hat es erst recht nicht.
```bash
CRM=https://<crm>
read -s -p "Passwort: " PASS; echo
AT=$(curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.token')
curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.user.permissions[]' | grep audit
```
Muss `audit:read` **und** `audit:admin` zeigen.
#### Erst nachsehen, was du festschreibst
Das Siegel hält den **aktuellen** Zustand fest, samt aller vorhandenen Lücken.
Wer blind siegelt, beglaubigt womöglich eine Lücke, die von einer Löschung
stammt. Der Schritt ist praktisch einwegs: Danach ist die Beglaubigung tragend,
und ein Siegel wieder zu entfernen erzeugt den Zustand `entfernt` also selbst
einen Befund.
```bash
B="Authorization: Bearer $AT"
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
| jq '.data | {valid, checkedCount, rehashes, chainGaps, unexplainedGaps,
tamperedEntries, backlogSealStatus}'
```
Vier Dinge müssen stimmen:
| Feld | Erwartung | Warum |
|---|---|---|
| `rehashes` | `[]` | Eine Neuberechnung verknüpft alles neu danach ist die Kette *zwangsläufig* stimmig, auch über Löschungen hinweg. Steht hier etwas, sagt `chainGaps` nichts über die Zeit davor aus. |
| `tamperedEntries` | `[]` | Veränderter Inhalt gehört geklärt, nicht beglaubigt. |
| `chainGaps` | erklärbar | siehe unten |
| `backlogSealStatus` | `kein_siegel` | sonst ist es kein Erst-Siegeln (siehe nächster Abschnitt) |
**Lücken einordnen.** Sieh dir die betroffenen IDs und ihre Nachbarn an:
```bash
for id in <lücke-1> <nachbar> …; do
printf '%-6s ' "$id"
curl -s -o /tmp/r.json -w 'HTTP %{http_code} ' "$CRM/api/audit-logs/$id" -H "$B"
jq -r 'if .success then (.data|"\(.createdAt[0:19]) v\(.hashVersion) \(.action)/\(.resourceType)") else .error end' /tmp/r.json
done
```
> **Den HTTP-Code mit ausgeben, immer.** Zugangstoken laufen nach 15 Minuten ab.
> Ohne den Code liest sich eine abgelehnte Anfrage (401) wie ein fehlender
> Eintrag ein Prüfwerkzeug, das ein verweigertes Lesen als Löschung meldet.
> Uns ist genau das passiert, mitten in der Vorbereitung eines Prod-Siegels.
Liegen die Lücken jeweils **innerhalb einer Sekunde** zusammen mit ihren
Nachbarn, betreffen nur `Authentication` und fehlt kein Eintrag, sind es
Schreibkollisionen aus parallelen Anfragen harmlos, historisch, nicht mehr
reproduzierbar. Fehlt dagegen ein Eintrag oder passt eine Lücke nicht in dieses
Bild: **erst klären, dann siegeln.**
*(Ein abgeschnittener **Anfang** des Protokolls wird seit dem Anfangs-Detektor
mitgeprüft: Die erste Zeile eines Protokolls trägt einen leeren `previousHash`;
trägt die erste vorhandene Zeile einen Wert, fehlt eine Vorgängerin. Das
erscheint als ganz niedrige Lücke und kippt `valid` du musst es nicht selbst
suchen.)*
#### Siegeln
```bash
curl -s -X POST $CRM/api/audit-logs/seal-backlog -H "$B" \
-H 'Content-Type: application/json' -d '{"confirm":"SEAL"}' | jq
```
`SEAL` ist das Erst-Siegeln. Besteht bereits ein Siegel, verlangt der Endpunkt
stattdessen `RESEAL` siehe nächster Abschnitt. Notiere `root` aus der Antwort.
#### Danach: drei Punkte gegenprüfen
```bash
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
| jq '.data | {valid, chainGaps, attestedGaps, backlogSealStatus, backlogSealCount}'
SID=$(curl -s "$CRM/api/audit-logs?resourceType=AuditBacklogSeal&limit=1" -H "$B" | jq -r '.data[0].id')
curl -s "$CRM/api/audit-logs/$SID" -H "$B" \
| jq '{vorbefund:.data.changesBefore.befund.ketten_luecken, siegel:.data.changesAfter}'
```
1. `valid: true`, und `chainGaps` = `attestedGaps` = die bekannten Lücken
2. `backlogSealStatus: "intakt"` **nicht** `leer`. `leer` heißt: Das Siegel
umschließt nichts, es gab keinen Altbestand. Kein Fehler, aber auch keine
Zusage.
3. Die Lücken stehen im `changesBefore.befund.ketten_luecken` des Markers
genau daran hängt die Beglaubigung.
Die Lücken verschwinden also **nicht** aus dem Bericht. Sie zählen nur nicht
mehr als offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte
Altzeile und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
#### Zuletzt: das Gegenbuch quittieren
Der nächste Lauf meldet **einmal** `exit=2` („Erstmals ein Bestandssiegel
gesetzt") erwartet, kein Befund. Wurzel aus `data/<instanz>/status.txt` in
`PROD_SEAL_ACK` bzw. `STAGING_SEAL_ACK` eintragen, `docker compose up -d`, nach
dem Lauf mit `exit=0` wieder leeren. Details im nächsten Abschnitt.
Ab da schreibt das Gegenbuch wieder Checkpoints fort und setzt im selben Lauf
die Grundlage für die Rehash-Überwachung.
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
Erneutes Siegeln **ersetzt** die Grundlage, gegen die Manipulation nachgewiesen
wird. Wer eine Altzeile per Datenbankzugriff entfernt und danach neu siegelt,
bekommt eine passende Wurzel und eine beglaubigte Lücke — und die Prüfung im
CRM meldet wieder `valid: true`. Der Wechsel der Wurzel ist die einzige Spur
davon, die eine Maschine sehen kann.
Deshalb ist er ein **Alarm** (exit 2), kein Hinweis. Vorher stand hier eine
Zeile Prosa, während der Rückgabecode auf 0 blieb — also genau das Muster, das
wir dem CRM selbst zweimal angekreidet haben.
Der Alarm nennt die alte und die neue Wurzel samt Blattzahl. Ein Sprung von
`10 Blätter` auf `9 Blätter` sagt sofort, was passiert ist.
**War der Wechsel gewollt** (typisch: dein einmaliges Erstsiegeln), bestätigst
du ihn mit der Wurzel, die der Alarm ausgibt:
```bash
# in der .env
PROD_SEAL_ACK=72062c88a8b6e2b53b30496b483885cd
docker compose up -d # ein Lauf die neue Wurzel wird beglaubigt
PROD_SEAL_ACK= # danach wieder leeren
```
Bestätigt wird bewusst **nicht** mit `true`, sondern mit der Wurzel selbst.
Ein versehentlich stehen gelassener Wert passt beim nächsten Wechsel nicht mehr
und kann deshalb keinen weiteren Austausch stillschweigend durchwinken.
**War er nicht gewollt**, sieh im CRM nach: das Sicherheits-Ereignis
`AUDIT_SEAL_CHANGED` (Einstellungen → Monitoring) und die CRITICAL-Zeile zu
`/api/audit-logs/seal-backlog` im Audit-Protokoll nennen Konto, Zeitpunkt und
den Befund, der vor dem Siegeln galt.
Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
### Wenn die Kette neu berechnet wurde: ebenfalls Alarm
Ein **Rehash** verknüpft alle Einträge neu. Danach ist die Kette
zwangsläufig stimmig — auch über Löschungen hinweg, die vorher als Lücken
sichtbar gewesen wären. Die Reihenfolge `cleanup``rehash` macht aus einem
beschnittenen Protokoll ein scheinbar makelloses, und die Prüfung im CRM meldet
danach wieder „lückenlos verkettet". Beides braucht nur `audit:admin`, keinen
Datenbankzugriff.
Genau das ist im Betrieb vorgekommen: Auf einer Testinstanz wurden 3.155
Einträge gelöscht und anschließend neu berechnet. Die Prüfung war danach grün,
die 656 Kettenlücken standen nur noch im Vorbefund des Rehash-Eintrags — den
niemand liest. Das Gegenbuch war der einzige Zeuge.
Deshalb ist eine neue Neuberechnung seit der letzten Beglaubigung ein **Alarm**
(exit 2). Er nennt Zeitpunkt, Zahl der betroffenen Zeilen und den Befund, der
unmittelbar davor galt:
```
ALARM: Die Hash-Kette wurde neu berechnet (1 neuer Vorgang seit der letzten Beglaubigung).
zuletzt: 2026-08-26T16:28:25.467Z (Eintrag 13, 9 Zeilen)
Befund unmittelbar davor: 1 beanstandet, 1 Lücken
NOTARY_REHASH_ACK=13
```
**War die Neuberechnung gewollt**, bestätigst du sie mit der genannten ID:
```bash
# in der .env
PROD_REHASH_ACK=13
docker compose up -d
PROD_REHASH_ACK= # danach wieder leeren
```
Auch hier wird nicht mit `true` bestätigt, sondern mit einem Wert, der zum
Vorgang gehört. IDs steigen streng — ein stehen gelassener Wert passt bei der
nächsten Neuberechnung nicht mehr.
**Warum ein eigener Melder, wo doch schon der Kettenkopf verglichen wird?**
Eine Neuberechnung ändert jeden Hash, der beglaubigte Kopf stimmt also
ohnehin nicht mehr — der Alarm käme auch so. Aber als *Nebenwirkung*, nicht als
gebaute Warnung: Verschöbe sich der Anker irgendwann, wäre der Melder lautlos
weg. Und die Meldung hieße „Eintrag wurde verändert" statt „die Kette wurde neu
berechnet" — die Wirkung statt der Ursache. Deshalb hängt der Alarm an der
Sache selbst und steht **vor** dem Kopf-Vergleich; nach einer bestätigten
Neuberechnung wird dieser übersprungen, weil der veränderte Kopf dann die
erwartete Folge ist.
Ein fehlender beglaubigter Eintrag bleibt davon unberührt und alarmiert immer:
Bestätigt wird die Neuberechnung, nicht das Verschwinden von Zeilen.
### Zugang einrichten (das brauchst du vorher)
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM kein Token. Der
Grund: Zugangstoken laufen nach 15 Minuten ab und wären beim nächsten
stündlichen Durchlauf längst ungültig. Das Gegenbuch meldet sich deshalb bei
jedem Lauf selbst an.
Im CRM, als Administrator:
1. **Benutzer anlegen**, z. B. `gegenbuch@deine-domain.de`, mit einem langen,
zufälligen Passwort.
2. Ihm die Rolle **`Gegenbuch`** geben **nur diese**. Sie bringt genau ein
Recht mit: `audit:read`.
3. Zusätzlich **„Dienstkonto"** ankreuzen. Dann gelten seine Anmeldungen als
Routine statt als kritisches Ereignis und sein *Ausbleiben* wird gemeldet.
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
> **Weder den DSGVO- noch den Audit-Betrieb-Haken setzen.** Der DSGVO-Haken
> gibt zusätzlich Leserechte auf personenbezogene Daten und den Export; der
> Haken „Audit-Betrieb" gibt `audit:admin` mit `seal-backlog`, `rehash` und
> `cleanup`. Ein Einbruch auf dieser Maschine hätte damit nicht nur den
> Wächter, sondern gleich die Mittel, das Bewachte umzuschreiben. Das Passwort
> steht hier im Klartext in der `.env`; es muss so wenig wert sein wie möglich.
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** keine
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
die Zugangsdaten abhandenkommen, ist damit nichts anzufangen.
Gegenprobe nach dem Einrichten **200, dann dreimal 403**:
```bash
B="Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w 'checkpoint %{http_code}\n' https://<crm>/api/audit-logs/checkpoint -H "$B"
curl -s -o /dev/null -w 'export %{http_code}\n' https://<crm>/api/audit-logs/export -H "$B"
curl -s -o /dev/null -w 'seal-backlog %{http_code}\n' -X POST https://<crm>/api/audit-logs/seal-backlog -H "$B" -H 'Content-Type: application/json' -d '{}'
curl -s -o /dev/null -w 'kunden %{http_code}\n' https://<crm>/api/customers -H "$B"
```
Der Export gehört ausdrücklich dazu: Er liefert `changesBefore`/`changesAfter`,
also die vollständigen Vorher/Nachher-Datensätze samt Klartextnamen. Prüfwerte
lesen und das Protokoll herausziehen sind zwei verschiedene Dinge deshalb
hängt der Export an `audit:export`, das die Rolle `Gegenbuch` nicht hat.
Für Produktion und Test jeweils ein eigenes Konto in der jeweiligen Instanz.
> Jede Anmeldung erscheint im Audit-Log der jeweiligen Instanz. Das ist so
> gewollt: Man sieht, dass das Gegenbuch arbeitet und wenn es aufhört, fällt
> auch das auf.
### Wer redet mit wem
```
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
```
Das ist der eigentliche Schutz. Das Gegenbuch **holt** es lässt sich nichts
schicken. OpenCRM kennt weder Adresse noch Schlüssel des Gegenbuchs. Wer
OpenCRM übernimmt, kommt hier nicht heran.
Das Token kann ausschließlich Prüfwerte lesen: keine Kundendaten, keine
Änderungen. Selbst wenn es abhandenkommt, ist damit nichts anzufangen.
Der Signaturschlüssel wird beim ersten Start **auf dem Gegenbuch-Rechner
erzeugt** und verlässt ihn nie.
### Wo die Daten liegen
Im Projektverzeichnis, nicht in Docker-Volumes:
```
tools/audit-notary/data/prod/
buch/ das Gegenbuch selbst (ein Git-Repository)
schluessel/ Signaturschlüssel wird beim ersten Start erzeugt
beobachtungen.jsonl was zuletzt gesehen wurde
status.txt Ergebnis des letzten Durchlaufs
```
Der Inhalt ist vom Repository ausgenommen der Signaturschlüssel gehört dort
nicht hinein.
**Zu Dateirechten:** Der Container startet kurz als `root`, setzt das
Datenverzeichnis auf den Arbeitsbenutzer um und gibt die Privilegien dann ab.
Das ist nötig, weil das Verzeichnis vom Host kommt wer das Projekt als `root`
geklont hat, hätte sonst ein Verzeichnis, in das der Container nicht schreiben
darf. Passiert automatisch, du musst nichts tun. Ein anderer Zielbenutzer geht
über `PUID`/`PGID`. Die Verzeichnisse selbst sind über `.gitkeep` vorhanden, damit
sie nach einem `git clone` schon existieren und Docker sie nicht als `root`
anlegt.
**Ins Backup gehört genau dieses Verzeichnis.** Geht es verloren, beginnt die
Beobachtung von vorn und der nächste Lauf sagt das ausdrücklich, statt „alles
gut" zu melden.
### Überwachung
Jeder Durchlauf schreibt seinen Stand nach `data/<instanz>/status.txt`:
```
2026-08-22T16:49:50+00:00 exit=0 in Ordnung
```
**Alles außer `exit=0` gehört angesehen.** Wer eine Überwachung hat, greift
diese Datei ab; wer keine hat, schaut regelmäßig mit `docker compose logs`
hinein. Ein Alarm, den niemand liest, ist keiner.
### Was dieser Betrieb abdeckt und was nicht
**Abgedeckt:** Jemand verändert oder löscht nachträglich Einträge im CRM
auch mit direktem Datenbankzugriff. Das widerspricht dem Gegenbuch und fällt
beim nächsten Durchlauf auf.
**Nicht abgedeckt:** Jemand übernimmt den Gegenbuch-Rechner selbst. Dagegen
hülfe nur eine zusätzliche Ablage außerhalb (z. B. ein privates Git-Repository
auf einem dritten Server) das ist vorbereitet, aber für die meisten
Installationen mehr Aufwand als Nutzen. Die Erfolgsmeldung benennt diese Grenze
bei jedem Lauf, damit sie nicht in Vergessenheit gerät.
---
---
# Zusätzliche Härtung: externes Repository
> **Alles ab hier gilt nur für die zweite Betriebsart.** Wer das Gegenbuch
> lokal auf einer eigenen Maschine betreibt der Normalfall, siehe oben kann
> diesen gesamten Abschnitt überspringen. Die Anforderungen darin (Git-Server,
> Rewind-Sperre, geschützte Refs) beziehen sich auf ein zusätzliches Repository
> auf einem dritten Server und existieren im lokalen Betrieb nicht.
Sinn der Variante: Beim lokalen Betrieb liegt das Buch auf demselben Rechner
wie der Signaturschlüssel. Wer diesen Rechner übernimmt, kann beides
manipulieren. Ein zusätzliches Repository auf einem dritten Server, das nur
Anhängen erlaubt, schließt auch das vorausgesetzt, dessen Regeln stimmen.
## Einrichten ohne Docker
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, ungültige Signatur, oder die Wurzel des Bestandssiegels hat sich geändert (`NOTARY_SEAL_ACK`) |
| 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 |
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt `valid` allein bleibt dabei `true`** |
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
| **`cleanup` + `rehash` ohne erneutes Siegeln** (Wäsche ohne Datenbankzugriff) | **neue Neuberechnung seit der letzten Beglaubigung `valid` und Kette sind danach makellos** |
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.