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:
2026-08-21 15:20:55 +02:00
co-authored by Claude Opus 5
parent 2d55fd23f9
commit f3ded9afbc
6 changed files with 329 additions and 0 deletions
+137
View File
@@ -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}).`);