Gegenbuch: externe Notarisierung der Audit-Kette
Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in derselben Datenbank, die sie absichern sollen - der Pentest hat das ueber mehrere Runden Schicht fuer Schicht gezeigt, zuletzt in R174-01 am Siegel-Marker selbst. Aufteilung nach der Analyse des Pentesters (der Schutz kommt vom Ort, nicht von der Signatur): Das CRM liefert nur einen lesbaren Kontrollwert ohne Geheimnisse (GET /api/audit-logs/checkpoint, audit:read). Signiert, zeitgestempelt und angehaengt wird auf einem anderen Rechner - Schluessel und Push-Recht liegen nicht in den Deploy-Secrets des CRM. Ohne diese Trennung waere es D1 nochmal, nur schlimmer: sieht nach doppeltem Boden aus, tut still nichts. Der Kontrollwert enthaelt bewusst maxId. Ein blosser Kopf-Hash erkennt Umschreiben, aber kein Abschneiden am Ende - genau die R174-01-Klasse, eine Ebene hoeher. atId erlaubt der Gegenstelle, einen frueher beglaubigten Kopf erneut abzufragen und nachzurechnen. Gegenstelle: tools/audit-notary/notary.mjs (Cron auf zweitem Rechner, privates Git-Repo als Append-only-Ablage, signierte Commits). Prueft vor dem Anhaengen und bricht bei Widerspruch mit Exit-Code 2 ab, ohne zu schreiben. Verifiziert gegen eine CRM-Attrappe mit echter DB: beglaubigte Zeile veraendert -> Alarm; am Ende abgeschnitten (maxId 5->4) -> Alarm; Gegenbuch selbst gekuerzt (seq-Luecke) -> Alarm; in allen Faellen nichts angehaengt. Ehrlich dokumentiert: Restfenster zwischen zwei Laeufen bleibt und ist inhaerent; ein stiller Cron-Ausfall erzeugt im CRM keine Warnung und muss auf dem Gegenbuch-Rechner ueberwacht werden; Force-Push muss serverseitig gesperrt sein, sonst ist Append-only nur geliehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# 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`.
|
||||
|
||||
## 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 |
|
||||
|
||||
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.
|
||||
Im Betrieb niemals setzen.
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OpenCRM – Gegenbuch (externe Notarisierung)
|
||||
* ===========================================
|
||||
*
|
||||
* WICHTIG: Dieses Skript laeuft NICHT auf dem CRM-Server. Es gehoert auf einen
|
||||
* zweiten Rechner. Der Signaturschluessel und das Push-Recht auf das private
|
||||
* Repository leben dort – nicht in den Deploy-Secrets des CRM.
|
||||
*
|
||||
* Der Grund: Alle Anker im CRM selbst (Hash-Kette, Versionsgrenze, Manifest,
|
||||
* Bestandssiegel) sitzen in derselben Datenbank, die sie schuetzen sollen. Wer
|
||||
* dort schreiben kann, kommt am Ende an jeden davon heran. Ein Gegenbuch an
|
||||
* einem fremden Ort durchbricht das – aber NUR, solange der Schluessel dieses
|
||||
* Ortes nicht im selben Zugriffsbereich liegt. Liegt er es doch, sieht es nach
|
||||
* doppeltem Boden aus und ist keiner.
|
||||
*
|
||||
* Ablauf je Lauf:
|
||||
* 1. Kontrollwert beim CRM abholen (nur lesend)
|
||||
* 2. GEGEN die eigene Historie pruefen, BEVOR etwas angehaengt wird
|
||||
* 3. Nur bei Unauffaelligkeit anhaengen, signiert committen, pushen
|
||||
* 4. Bei Widerspruch: Exit-Code 2 und nichts schreiben
|
||||
*
|
||||
* Aufruf (Beispiel-Cron, stuendlich):
|
||||
* 0 * * * * cd /pfad/zum/gegenbuch && CRM_URL=https://crm.example.de \
|
||||
* CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const CRM_URL = process.env.CRM_URL;
|
||||
const CRM_TOKEN = process.env.CRM_TOKEN;
|
||||
const LEDGER = resolve(process.env.LEDGER_FILE || 'checkpoints.jsonl');
|
||||
const PUSH = process.env.NOTARY_PUSH !== 'false';
|
||||
// NUR fuer Tests ohne hinterlegten Schluessel. Im Betrieb NIEMALS setzen: ohne
|
||||
// Signatur kann jeder, der Schreibrecht auf das Repository erlangt, die
|
||||
// Checkpoint-Reihe faelschen - dann ist das Gegenbuch wertlos.
|
||||
const SIGN = process.env.NOTARY_SIGN !== 'false';
|
||||
|
||||
if (!CRM_URL || !CRM_TOKEN) {
|
||||
console.error('CRM_URL und CRM_TOKEN müssen gesetzt sein.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||
|
||||
async function hole(pfad) {
|
||||
const r = await fetch(`${CRM_URL}${pfad}`, {
|
||||
headers: { Authorization: `Bearer ${CRM_TOKEN}` },
|
||||
});
|
||||
if (!r.ok) throw new Error(`${pfad} -> HTTP ${r.status}`);
|
||||
const j = await r.json();
|
||||
if (!j.success) throw new Error(`${pfad} -> ${j.error}`);
|
||||
return j.data;
|
||||
}
|
||||
|
||||
function ledgerLesen() {
|
||||
if (!existsSync(LEDGER)) return [];
|
||||
return readFileSync(LEDGER, 'utf8')
|
||||
.split('\n')
|
||||
.filter((z) => z.trim())
|
||||
.map((z) => JSON.parse(z));
|
||||
}
|
||||
|
||||
const bisher = ledgerLesen();
|
||||
|
||||
// --- seq lueckenlos und monoton pruefen -------------------------------------
|
||||
// Ohne diese Pruefung koennte jemand die Checkpoint-Reihe selbst hinten
|
||||
// kuerzen; die Luecke faellt dann nicht auf.
|
||||
for (let i = 0; i < bisher.length; i++) {
|
||||
if (bisher[i].seq !== i + 1) {
|
||||
console.error(`ALARM: Lücke in der Checkpoint-Reihe an Position ${i + 1} (seq=${bisher[i].seq}).`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const letzter = bisher[bisher.length - 1];
|
||||
const aktuell = await hole('/api/audit-logs/checkpoint');
|
||||
|
||||
// --- Abgleich VOR dem Anhaengen ---------------------------------------------
|
||||
if (letzter) {
|
||||
// 1) Abschneiden am Ende: der beglaubigte Stand darf nie schrumpfen.
|
||||
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
|
||||
console.error(
|
||||
`ALARM: Das Audit-Log ist geschrumpft. Beglaubigt war maxId=${letzter.maxId}, ` +
|
||||
`jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// 2) Umschreiben: der frueher beglaubigte Kopf muss reproduzierbar bleiben.
|
||||
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
|
||||
if (rueck.atHash === null) {
|
||||
console.error(`ALARM: Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (rueck.atHash !== letzter.chainHead) {
|
||||
console.error(
|
||||
`ALARM: Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
|
||||
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// 3) Bestandssiegel darf nicht klammheimlich verschwinden.
|
||||
if (letzter.sealRoot && !aktuell.sealRoot) {
|
||||
console.error('ALARM: Das Bestandssiegel ist verschwunden (vorher beglaubigt vorhanden).');
|
||||
process.exit(2);
|
||||
}
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Anhaengen, signieren, pushen -------------------------------------------
|
||||
const eintrag = {
|
||||
seq: bisher.length + 1,
|
||||
ts: aktuell.ts,
|
||||
maxId: aktuell.maxId,
|
||||
chainHead: aktuell.chainHead,
|
||||
sealRoot: aktuell.sealRoot,
|
||||
sealStatus: aktuell.sealStatus,
|
||||
};
|
||||
writeFileSync(LEDGER, [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n');
|
||||
|
||||
git('add', LEDGER);
|
||||
// -S signiert den Commit mit dem Schluessel DIESES Rechners.
|
||||
const commitArgs = ['commit', ...(SIGN ? ['-S'] : []), '-m',
|
||||
`Checkpoint ${eintrag.seq} – maxId ${eintrag.maxId} – ${eintrag.ts}`];
|
||||
if (!SIGN) console.warn('WARNUNG: Commit wird NICHT signiert (NOTARY_SIGN=false).');
|
||||
git(...commitArgs);
|
||||
if (PUSH) git('push');
|
||||
|
||||
console.log(`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).`);
|
||||
Reference in New Issue
Block a user