Files
opencrm/tools/audit-notary
duffyduckandClaude Opus 5 ed676ca4eb Versiegeln dokumentiert: Abschnitt aktualisiert und im Haupt-README verlinkt
Der Abschnitt in tools/audit-notary/README.md war an drei Stellen
ueberholt. Er sagte "nimm dein Administratorkonto" - seit der Aufteilung
von DSGVO und Audit-Betrieb hat die Admin-Rolle gar keine Audit-Rechte
mehr, das Recht kommt jetzt ueber den Haken "Audit-Betrieb". Er kannte
weder das RESEAL-Gate noch die Rehash-Anzeige noch den Zustand "leer".

Neu geschrieben, mit dem, was die Praxis gezeigt hat:
- Wer darf es, und warum nicht ueber DSGVO oder Admin
- Vier Felder, die vor dem Siegeln stimmen muessen - allen voran
  rehashes:[], denn nach einer Neuberechnung sagt chainGaps nichts mehr
  ueber die Zeit davor
- Den HTTP-Code beim Pruefen IMMER mit ausgeben: Token laufen nach 15
  Minuten ab, und ohne den Code liest sich eine 401 wie ein fehlender
  Eintrag. Genau das ist uns bei der Vorbereitung des Prod-Siegels
  passiert und haette fast zu einem falschen Stopp gefuehrt.
- Die drei Punkte zur Gegenpruefung danach, inklusive "intakt" statt
  "leer"
- Der Hinweis, dass ein unversiegeltes CRM nicht "bewacht mit Warnung"
  ist, sondern unbewacht: Das Gegenbuch bricht vor dem Anhaengen ab.

Ausserdem im Haupt-README verlinkt. Wer AUDIT_HMAC_KEY auf einer
bestehenden Installation setzt, hat zwangslaeufig einen Altbestand -
erfuhr davon aber nur, wenn er ein Gegenbuch betreibt. Die Anleitung gilt
auch ohne.

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

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.

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:

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.

# 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.

Wenn der erste Lauf exit=2 meldet: den Altbestand versiegeln

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.

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.

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:

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

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

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:

# 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 cleanuprehash 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:

# 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:

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:

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:

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:

# 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:

# 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:

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:

    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

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.