#!/usr/bin/env node /** * OpenCRM – Gegenbuch (externe Notarisierung) * =========================================== * * Laeuft NICHT auf dem CRM-Server, sondern auf einem zweiten Rechner. Der * Signaturschluessel und das Push-Recht leben dort – nicht in den * Deploy-Secrets des CRM. * * ZENTRALE LEHRE (Pentest R175-01): Append-only und Signieren allein reichen * nicht – es braucht einen VERIFIZIERENDEN LESER. Die erste Fassung las ihre * Wahrheit aus der lokalen Arbeitsdatei und hat nie eine Signatur geprueft; * das `-S` war write-only ohne Konsument. Wer den Notar-Host beschreiben * konnte, hat dem Skript beliebige „beglaubigte“ Zeilen untergeschoben und den * abgeschnittenen Zustand als neue Wahrheit zementiert – ohne Alarm. * * Deshalb gilt jetzt: * - Wahrheitsquelle ist der SIGNIERTE Commit-Baum (bevorzugt der Remote-Kopf), * nicht die Arbeitsdatei. * - JEDER Commit der Historie muss eine gute Signatur tragen. * - Weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen. * - Geschrieben wird erst nach erfolgreichem, signiertem Commit; schlaegt er * fehl, wird die Datei zurueckgerollt (Pentest R175-03). */ import { execFileSync } from 'node:child_process'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve, basename } 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 LEDGER_NAME = basename(LEDGER); const PUSH = process.env.NOTARY_PUSH !== 'false'; const REMOTE = process.env.NOTARY_REMOTE || 'origin'; // Reiner Pruefmodus fuer Auditoren: fuehrt alle Kontrollen aus, haengt aber // nichts an und braucht kein Schreibrecht. const NUR_PRUEFEN = process.argv.includes('--check') || process.env.NOTARY_CHECK_ONLY === 'true'; // Ohne Signatur ist das Gegenbuch wertlos: dann kann jeder mit Schreibrecht // die Reihe faelschen. Der Schalter existiert nur fuer Tests und verlangt eine // zusaetzliche, bewusste Bestaetigung – der Reflex "Cron hakt, also abschalten" // soll nicht ausreichen. const SIGN = process.env.NOTARY_SIGN !== 'false'; if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschuetzt-ist') { console.error( 'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' + 'Wenn das wirklich beabsichtigt ist (nur für Tests), zusätzlich setzen:\n' + ' NOTARY_INSECURE_ACK=mir-ist-klar-dass-das-ungeschuetzt-ist', ); process.exit(1); } 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(); const gitStill = (...args) => { try { return { ok: true, out: git(...args) }; } catch (e) { return { ok: false, out: String(e.stderr || e.message) }; } }; 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; } const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); }; // --------------------------------------------------------------------------- // 1) Vertrauenswuerdigen Stand bestimmen: bevorzugt der Remote-Kopf. // --------------------------------------------------------------------------- const zweig = git('rev-parse', '--abbrev-ref', 'HEAD'); let ref = 'HEAD'; if (gitStill('remote', 'get-url', REMOTE).ok) { const f = gitStill('fetch', '--quiet', REMOTE); if (!f.ok) console.warn(`HINWEIS: fetch von ${REMOTE} fehlgeschlagen – prüfe gegen lokalen Stand.\n${f.out}`); else if (gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) ref = `${REMOTE}/${zweig}`; } // Lokale Commits, die dem Remote voraus sind, sind nicht beglaubigt. if (ref !== 'HEAD') { const voraus = gitStill('rev-list', '--count', `${ref}..HEAD`); if (voraus.ok && Number(voraus.out) > 0) { alarm( `${voraus.out} lokale(r) Commit(s) wurden nie gepusht. Das Gegenbuch ist damit ` + `nur lokal – entweder pushen oder untersuchen, wie sie entstanden sind.`, ); } } // --------------------------------------------------------------------------- // 2) Signaturen der GESAMTEN Historie pruefen – das war der fehlende Leser. // --------------------------------------------------------------------------- const commits = gitStill('log', '--format=%H %G?', ref); if (!commits.ok) alarm(`Historie nicht lesbar: ${commits.out}`); const zeilen = commits.out.split('\n').filter((z) => z.trim()); if (SIGN) { const schlecht = zeilen .map((z) => z.split(' ')) .filter(([, status]) => status !== 'G' && status !== 'U'); // Der allererste (leere) init-Commit darf unsigniert sein, sonst nichts. const relevant = schlecht.filter(([sha]) => { const dateien = gitStill('show', '--name-only', '--format=', sha); return dateien.ok && dateien.out.includes(LEDGER_NAME); }); if (relevant.length) { alarm( `${relevant.length} Commit(s) mit Gegenbuch-Änderungen tragen KEINE gültige Signatur:\n ` + relevant.map(([sha, st]) => `${sha.slice(0, 12)} (%G?=${st})`).join('\n ') + `\nDie Reihe ist damit nicht vertrauenswürdig.`, ); } } // --------------------------------------------------------------------------- // 3) Wahrheit aus dem signierten Baum lesen – NICHT aus der Arbeitsdatei. // --------------------------------------------------------------------------- const ausBaum = gitStill('show', `${ref}:${LEDGER_NAME}`); const beglaubigterInhalt = ausBaum.ok ? ausBaum.out : ''; const arbeitsInhalt = existsSync(LEDGER) ? readFileSync(LEDGER, 'utf8').trim() : ''; if (arbeitsInhalt !== beglaubigterInhalt.trim()) { alarm( 'Die lokale Gegenbuch-Datei weicht vom signierten Stand ab. Entweder wurde sie ' + 'von Hand verändert, oder ein früherer Lauf ist zwischen Schreiben und Commit ' + 'abgebrochen. Nichts wird angehängt.\n' + ` Wiederherstellen mit: git checkout ${ref} -- ${LEDGER_NAME}`, ); } const bisher = beglaubigterInhalt .split('\n') .filter((z) => z.trim()) .map((z) => JSON.parse(z)); for (let i = 0; i < bisher.length; i++) { if (bisher[i].seq !== i + 1) { alarm(`Lücke in der Checkpoint-Reihe an Position ${i + 1} (seq=${bisher[i].seq}).`); } } // --------------------------------------------------------------------------- // 4) Abgleich mit dem CRM, VOR dem Anhaengen. // --------------------------------------------------------------------------- const letzter = bisher[bisher.length - 1]; const aktuell = await hole('/api/audit-logs/checkpoint'); if (letzter) { if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) { alarm( `Das Audit-Log ist geschrumpft. Beglaubigt war maxId=${letzter.maxId}, ` + `jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`, ); } const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`); if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`); if (rueck.atHash !== letzter.chainHead) { alarm( `Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` + ` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`, ); } if (letzter.sealRoot && !aktuell.sealRoot) { alarm('Das Bestandssiegel ist verschwunden (vorher beglaubigt vorhanden).'); } if (letzter.sealLeafCount > 0 && aktuell.sealLeafCount === 0) { alarm('Die Blattwerte des Bestandssiegels wurden entfernt.'); } 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.`, ); } } // --------------------------------------------------------------------------- // 5) Anhaengen – mit Ruecknahme, falls der signierte Commit scheitert. // --------------------------------------------------------------------------- if (NUR_PRUEFEN) { console.log( `OK: ${bisher.length} Checkpoint(s) geprüft – Signaturen gültig, Reihe lückenlos, ` + `Arbeitsdatei deckungsgleich mit dem signierten Stand` + (letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.'), ); process.exit(0); } const eintrag = { seq: bisher.length + 1, ts: aktuell.ts, maxId: aktuell.maxId, chainHead: aktuell.chainHead, sealRoot: aktuell.sealRoot, sealLeafCount: aktuell.sealLeafCount, }; const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n'; const zuruecknehmen = () => { // Ohne das bliebe eine uncommittete Zeile liegen, die der naechste Lauf // absorbiert und damit zementiert (Pentest R175-03). const r = gitStill('checkout', ref, '--', LEDGER_NAME); if (!r.ok && !ausBaum.ok) writeFileSync(LEDGER, ''); }; writeFileSync(LEDGER, neuerInhalt); try { git('add', LEDGER); git('commit', ...(SIGN ? ['-S'] : []), '-m', `Checkpoint ${eintrag.seq} – maxId ${eintrag.maxId} – ${eintrag.ts}`); } catch (e) { zuruecknehmen(); console.error(`Commit fehlgeschlagen, Gegenbuch unverändert zurückgesetzt.\n${e.stderr || e.message}`); process.exit(1); } // Signatur des frischen Commits gegenpruefen – sonst waere `-S` wieder nur // Absichtserklaerung. if (SIGN) { const st = git('log', '-1', '--format=%G?', 'HEAD'); if (st !== 'G' && st !== 'U') { git('reset', '--hard', 'HEAD~1'); alarm(`Der neue Commit trägt keine gültige Signatur (%G?=${st}). Zurückgerollt.`); } } if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert – das Gegenbuch ist ungeschützt.'); if (PUSH) { const p = gitStill('push', REMOTE, zweig); if (!p.ok) { console.error( `Push fehlgeschlagen – der Checkpoint liegt nur lokal und gilt nicht als beglaubigt.\n${p.out}`, ); process.exit(1); } } console.log(`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).`);