R177-01 (MEDIUM): Der Pin war optional. Ohne ihn war der Vertrauensanker die gesamte allowed_signers-Menge, nicht der eine Notar-Schluessel: ein zweiter dort gelisteter Schluessel konnte das Gegenbuch waschen und force-pushen, und %G? war G. R176-01 hatte "jeder selbst erzeugte Schluessel" geschlossen, "jeder erlaubte Schluessel" blieb offen. Fix: Pin wird aus user.signingkey abgeleitet; laesst er sich nicht bestimmen, wird abgebrochen statt die ganze Liste zu akzeptieren. R177-02 (MEDIUM): Der Schreib-Lauf prueft den frischen Commit nur auf %G?, nicht auf den Pin. Ein Notar-Host mit falsch konfiguriertem Schluessel meldete "beglaubigt" und pushte - und ab da war die Kette dauerhaft rot, behebbar nur per Force-Push, den die Branch-Protection gerade verhindern soll. Fix: Pin-Abgleich am frischen Commit vor dem Push, bei Abweichung Ruecknahme. Kleinkram: NOTARY_ALLOW_LOCAL faerbt Erfolgsmeldungen ein und pusht nicht mehr ins Leere; CRM-Fehler liefern eine erklaerende Zeile statt Node-Stacktrace. Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff -> Alarm exit 2 auch ohne gesetzten Pin; Schreiblauf mit falschem Schluessel -> zurueckgerollt, nichts gepusht; CRM nicht erreichbar/401 -> saubere Meldung; Lokalmodus eingefaerbt; saubere Historie ohne Fehlalarm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
6.6 KiB
Markdown
148 lines
6.6 KiB
Markdown
# Gegenbuch (externe Notarisierung)
|
||
|
||
Alle Schutzmechanismen im CRM selbst – Hash-Kette, Versionsgrenze,
|
||
Löschungs-Manifest, Bestandssiegel – liegen in **derselben Datenbank**, die sie
|
||
absichern sollen. Wer dort schreiben kann, sitzt am Ende immer schon auf der
|
||
Ebene, die den Beweis führt. Genau das hat der Pentest über mehrere Runden
|
||
Schicht für Schicht gezeigt.
|
||
|
||
Das Gegenbuch durchbricht das: Ein zweiter Rechner holt regelmäßig einen kurzen
|
||
Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und hängt ihn
|
||
signiert an ein privates Repository an.
|
||
|
||
## Die eine nicht verhandelbare Bedingung
|
||
|
||
**Der Signaturschlüssel und das Push-Recht dürfen nicht in den Deploy-Secrets
|
||
des CRM liegen.** Sonst ist der zweite Schlüssel im selben Zugriffsbereich wie
|
||
der erste – es sieht nach doppeltem Boden aus und ist keiner. Das CRM braucht
|
||
für dieses Verfahren **gar nichts** zu wissen: Es liefert nur einen lesbaren
|
||
Kontrollwert, der keine Geheimnisse enthält.
|
||
|
||
## Einrichten
|
||
|
||
Auf einem **anderen** Rechner als dem CRM-Server:
|
||
|
||
```bash
|
||
git clone git@host:dein/audit-gegenbuch.git
|
||
cd audit-gegenbuch
|
||
|
||
export CRM_URL=https://crm.example.de
|
||
export CRM_TOKEN=<Access-Token eines Benutzers mit audit:read>
|
||
|
||
node /pfad/zu/notary.mjs
|
||
```
|
||
|
||
Als Cronjob, stündlich:
|
||
|
||
```cron
|
||
0 * * * * cd /pfad/zum/gegenbuch && CRM_URL=https://crm.example.de \
|
||
CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1
|
||
```
|
||
|
||
**Force-Push serverseitig sperren.** Ohne das ist die Append-only-Eigenschaft
|
||
nur geliehen – wer Schreibrecht auf das Repository erlangt, schreibt die
|
||
Historie sonst einfach um. Bei GitHub/GitLab: Branch-Protection auf `main`.
|
||
|
||
## 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 |
|
||
| 3 | beglaubigter Stand nicht feststellbar (Remote fehlt oder unerreichbar) |
|
||
|
||
Für Cron gilt: **jeder** Code außer 0 gehört gemeldet. Code 2 ist der Alarm,
|
||
Code 3 heißt „ich weiß es nicht" – und das ist ausdrücklich kein Freibrief.
|
||
|
||
## Prüfmodus für Auditoren
|
||
|
||
```bash
|
||
node notary.mjs --check
|
||
```
|
||
|
||
Führt alle Kontrollen aus, hängt aber nichts an und braucht kein Schreibrecht.
|
||
Geeignet für jemanden, der die Kette unabhängig nachvollziehen will.
|
||
|
||
## 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 |
|
||
| 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.
|