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>
738 lines
34 KiB
Markdown
738 lines
34 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 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.
|