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>
138 lines
5.3 KiB
JavaScript
Executable File
138 lines
5.3 KiB
JavaScript
Executable File
#!/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}).`);
|