Files
opencrm/tools/audit-notary/notary.mjs
T
duffyduckandClaude Opus 5 f3ded9afbc 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>
2026-08-21 15:20:55 +02:00

138 lines
5.3 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}).`);