Siegelwechsel ist ein Alarm, kein Hinweis (Pentest R185-01/-02)

R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der
Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso
gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis
landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid`
ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per
DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder
true. Live reproduziert.

Zwei Schichten, in seiner Reihenfolge:

1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration
   20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit
   ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details
   halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest.
2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt
   {"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei
   verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe
   Wort haben wie das Einrichten.

Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein
console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer
Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung
sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend
uebernommen zu werden.

Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne
Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert
(R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern
die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken.

R185-02 (LOW): GET /api/audit-logs?action=<x> gab ungueltige Enum-Werte
roh an die Spalte -> 500. Zweifach schlecht: fehlende Validierung und
Fehler-Orakel (200 vs 500 verraet die Enum-Mitglieder). Jetzt 400 mit
der erlaubten Menge im Klartext. Mitgenommen: sensitivity, Datumsfelder,
Zahlenfelder, Textlaengen und ein Deckel auf limit (200), ueber den sich
sonst die ganze Tabelle an der Seitenlogik vorbei ziehen liess. Beide
Endpunkte.

Getestet ueber HTTP gegen eine Wegwerf-DB, inkl. echtem Gegenbuch-Lauf
mit SSH-signiertem lokalem Repo. Zusaetzlich nachgeholt, was der Tester
nicht herstellen konnte: vollstaendig unsigniertes Protokoll ->
kein_siegel statt der frueheren falschen Entwarnung nicht_noetig, und
seal-backlog nennt den fehlenden Schluessel als naechsten Schritt.
Gegenrichtung geprueft, R183-03 bleibt behoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 09:41:11 +02:00
co-authored by Claude Opus 5
parent 7af6b7591b
commit e81a83ae8f
10 changed files with 365 additions and 28 deletions
+12
View File
@@ -56,6 +56,17 @@ PROD_GENESIS_ACK=
# geklärt hast, warum. Siehe README, Abschnitt "Wenn das Gedächtnis fehlt".
PROD_ADOPT_ACK=
# Normalerweise leer lassen.
# Gültige Werte: die neue Siegelwurzel (mind. 16 Zeichen) | (leer)
# Das Gegenbuch schlägt Alarm, wenn sich die Wurzel des Bestandssiegels
# ändert denn ein erneutes Siegeln ersetzt die Grundlage, gegen die
# Manipulation nachgewiesen wird. War der Wechsel gewollt, hier die Wurzel
# eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
# Bewusst KEIN "true": ein stehen gelassener Wert passt beim nächsten
# Wechsel nicht mehr und kann darum keinen weiteren stillschweigend
# durchwinken.
PROD_SEAL_ACK=
# Wo das Buch liegt relativ zu diesem Verzeichnis.
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
PROD_DIR=./data/prod
@@ -71,4 +82,5 @@ STAGING_CRM_PASSWORD=
STAGING_INTERVAL=3600
STAGING_GENESIS_ACK=
STAGING_ADOPT_ACK=
STAGING_SEAL_ACK=
STAGING_DIR=./data/staging
+42 -1
View File
@@ -138,6 +138,45 @@ Sie 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.
### 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.
### Zugang einrichten (das brauchst du vorher)
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM kein Token. Der
@@ -442,7 +481,7 @@ Deshalb gilt jetzt:
|---|---|
| 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 |
| 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 |
@@ -491,6 +530,8 @@ an, bis er geklärt ist.
| 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 |
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
+3
View File
@@ -34,6 +34,8 @@ services:
NOTARY_GENESIS_ACK: ${PROD_GENESIS_ACK:-}
# Nur nach geklärtem Verlust des Beobachtungsspeichers, siehe README:
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
volumes:
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
# Dieses Verzeichnis ist das Gegenbuch es gehört ins Backup.
@@ -53,5 +55,6 @@ services:
NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost}
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
volumes:
- ${STAGING_DIR:-./data/staging}:/gegenbuch
+70 -3
View File
@@ -135,6 +135,24 @@ if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschu
const CRM_EMAIL = process.env.CRM_EMAIL;
const CRM_PASSWORD = process.env.CRM_PASSWORD;
// Bestaetigung fuer einen Wechsel der Siegelwurzel (Pentest R185-01).
//
// Ein Wechsel loest Alarm aus und der Alarm bricht ab, BEVOR der neue Stand
// ins Gegenbuch kommt. Ohne Bestaetigungsweg wuerde deshalb auch ein voellig
// legitimes Siegeln von da an bei jedem Lauf erneut alarmieren, ohne dass der
// Betreiber es je aufloesen koennte. Genau daran stirbt eine Warnung
// (dieselbe Lehre wie R183-03).
//
// Bestaetigt wird deshalb nicht pauschal mit „true“, sondern mit der WURZEL,
// die man akzeptiert mindestens 16 Zeichen. Ein versehentlich stehen
// gelassener Wert passt beim naechsten Wechsel nicht mehr und kann darum
// keinen weiteren Austausch stillschweigend durchwinken. Das ist der
// Unterschied zu NOTARY_GENESIS_ACK, wo genau diese Falle dokumentiert
// werden musste.
const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim();
const siegelBestaetigt = (wurzel) =>
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
if (!CRM_URL) {
console.error('CRM_URL muss gesetzt sein.');
process.exit(1);
@@ -774,10 +792,59 @@ if (letzter) {
if (letzter.sealLeafCount > 0 && aktuell.sealLeafCount === 0) {
alarm('Die Blattwerte des Bestandssiegels wurden entfernt.');
}
// Wechsel der Siegelwurzel ist ein ALARM, kein Hinweis (Pentest R185-01).
//
// Vorher stand hier ein console.warn und der Rueckgabecode blieb 0 also
// genau das Muster, das wir dem CRM zweimal angekreidet haben: die Warnung
// steht in der Prosa, die Maschine meldet „in Ordnung“. Das ist hier
// besonders teuer, weil `valid` ein ERSETZENDES Siegel per Konstruktion
// ueberlebt: Wer eine Altzeile per Datenbankzugriff entfernt und danach neu
// siegelt, bekommt eine passende Wurzel und eine beglaubigte Luecke. Die
// Vollpruefung sagt dann `true`. Der Wurzelwechsel ist der EINZIGE
// maschinell erkennbare Anker dagegen und der gehoert in den Alarm.
//
// Ein legitimes Neu-Siegeln loest hier einmal aus. Das ist gewollt: ein
// Austausch der Beweisgrundlage soll einmal wehtun und bestaetigt werden,
// statt lautlos durchzulaufen.
if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) {
console.warn(
`HINWEIS: Das Bestandssiegel wurde erneuert (${letzter.sealRoot.slice(0, 12)}… → ` +
`${aktuell.sealRoot.slice(0, 12)}). Legitim nach einem Retention-Lauf sonst prüfen.`,
if (siegelBestaetigt(aktuell.sealRoot)) {
console.log(
`Siegelwechsel bestätigt (NOTARY_SEAL_ACK): ${letzter.sealRoot.slice(0, 16)}` +
`${aktuell.sealRoot.slice(0, 16)}…. Die neue Wurzel wird beglaubigt.\n` +
' NOTARY_SEAL_ACK danach wieder leeren.',
);
} else alarm(
'Das Bestandssiegel wurde ERSETZT die beglaubigte Grundlage ist eine andere.\n' +
` beglaubigt: ${letzter.sealRoot.slice(0, 16)}… (${letzter.sealLeafCount} Blätter)\n` +
` jetzt : ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
'Ein erneutes Siegeln schreibt den AKTUELLEN Stand des Altbestands fest. Wurden\n' +
'vorher Einträge entfernt, sind deren Lücken danach beglaubigt und die Prüfung im\n' +
'CRM meldet wieder valid:true dieser Wurzelwechsel ist die einzige Spur davon.\n' +
'Wenn das keine geplante Maßnahme war: Im CRM das Ereignis AUDIT_SEAL_CHANGED und\n' +
'die CRITICAL-Zeile zu /api/audit-logs/seal-backlog ansehen (wer, wann, Vorbefund).\n' +
'War es geplant, den Wechsel bestätigen und danach wieder leeren:\n' +
` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
);
}
// Erstsiegelung: vorher nichts beglaubigt, jetzt eine Wurzel. Das ist der
// eine legitime Einrichtungsschritt aber auch das Fenster, in dem ein
// beschnittener Altbestand einmalig festgeschrieben werden koennte. Deshalb
// ebenfalls melden, mit eigenem Text statt stillschweigend zu uebernehmen.
if (!letzter.sealRoot && aktuell.sealRoot) {
if (siegelBestaetigt(aktuell.sealRoot)) {
console.log(
`Erstsiegelung bestätigt (NOTARY_SEAL_ACK): ${aktuell.sealRoot.slice(0, 16)}…. ` +
'Die Wurzel wird beglaubigt.\n NOTARY_SEAL_ACK danach wieder leeren.',
);
} else alarm(
'Erstmals ein Bestandssiegel gesetzt bisher war keines beglaubigt.\n' +
` Wurzel: ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
'War das dein Einrichtungsschritt, ist alles in Ordnung: Der nächste Lauf läuft\n' +
'wieder auf 0, sobald diese Wurzel im Gegenbuch steht.\n' +
'War es das NICHT, dann hat jemand den Stand des Altbestands festgeschrieben \n' +
'samt aller Lücken, die zu diesem Zeitpunkt bestanden.\n' +
'Zum Bestätigen setzen und danach wieder leeren:\n' +
` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
);
}
}