Projektkonvention wie beim Hauptstack: Bind-Mount auf tools/audit-notary/data/<instanz>/ statt benannter Volumes. Wichtiger Nebeneffekt, der vorher fehlte: Das Verzeichnis war nicht von der Versionsverwaltung ausgenommen - Signaturschluessel und Gegenbuch waeren beim naechsten Commit im Repository gelandet. Jetzt ist der Inhalt ignoriert, waehrend die Verzeichnisse selbst ueber .gitkeep bestehen bleiben. Letzteres ist noetig, weil Docker fehlende Bind-Mount-Ziele als root anlegt und der Container als UID 1000 laeuft - der erste Start waere sonst am Schreibrecht gescheitert. Verifiziert mit echtem docker compose gegen eine CRM-Attrappe: Buch, Schluessel, Beobachtungsspeicher und status.txt landen unter tools/audit-notary/data/prod/, Normalbetrieb exit 0. Testcontainer, Image, Attrappe und .env danach entfernt; nur die drei .gitkeep bleiben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
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 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
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.
Wer redet mit wem
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Token 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. 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.
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 aufmain.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 – einpre-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 ;; esacDeshalb 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
--checkaus 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_signersmuss den Notar-Schlüssel enthalten. Das Skript akzeptiert ausschließlich Signaturen, die git alsGbewertet – bei SSH-Signaturen heißt das wörtlich „Schlüssel steht inallowed_signers". Ein fremder, selbst erzeugter Schlüssel liefertUund wird abgelehnt. (Bei GPG statt SSH liefert auch ein legitimer Schlüssel ohne Ownertrust einU– dann Ownertrust setzen, nichtUwieder 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_signerskann 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.signingkeyabgeleitet. 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 vonssh-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
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=falseexistiert nur für Tests ohne hinterlegten Schlüssel und verlangt zusätzlichNOTARY_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.