#!/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}).`);