#!/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, appendFileSync, mkdirSync } from 'node:fs'; import { resolve, basename, join, dirname } from 'node:path'; import { homedir } from 'node:os'; 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'; // Wird gesetzt, wenn ohne externen Anker gearbeitet wird – faerbt jede // Erfolgsmeldung ein (Pentest R177, Kleinkram). let NUR_LOKAL = false; // 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'; // Fingerabdruck des erwarteten Signierschluessels (`%GF`). // // NICHT optional (Pentest R177-01): Ohne Pin ist der Vertrauensanker die // gesamte `allowed_signers`-MENGE, nicht der eine Notar-Schluessel. Jeder dort // gelistete Zweitschluessel – anderer Nutzer, Alt-Key, global geteilte Datei – // kann das Gegenbuch dann waschen und force-pushen, und die Pruefung meldet // „gültig“. Live gezeigt. // // Ist nichts gesetzt, wird der Pin aus `user.signingkey` abgeleitet. Klappt // auch das nicht, wird abgebrochen statt stillschweigend die ganze Menge zu // akzeptieren. // `.trim()` beim EINLESEN (Pentest R178): ein Zeilenumbruch aus der // Umgebungsvariable liess sonst jeden Commit als „anderer Schlüssel“ auflaufen – // ein Fehlalarm, der auf den korrekten Fingerabdruck zeigt und damit maximal // verwirrt. let PIN = (process.env.NOTARY_SIGNER_FINGERPRINT || '').trim() || null; // Bekannte Mindesthoehe der Checkpoint-Reihe (Pentest R178-01). // // Ein Angreifer mit Force-Push-Recht kann die Reihe auf einen AELTEREN, // echt signierten Praefix zurueckspulen und die Datenbank passend kuerzen. // Alle Signaturen bleiben gueltig, der Pin passt, die Reihe ist lueckenlos – // signaturseitig ist daran nichts auszusetzen. Ein Notar-Klon mit lokalem // Vorlauf merkt es (er erinnert die hoehere seq); ein FRISCHER Auditoren-Klon // merkt es nicht. // // Wer die zuletzt gesehene seq kennt (aus Monitoring, Protokoll, Mail), kann // sie hier mitgeben – dann faellt das Zurueckspulen auch ohne lokalen Zustand // auf. const MIN_SEQ_ROH = (process.env.NOTARY_MIN_SEQ || '').trim(); let MIN_SEQ = Number.NaN; if (MIN_SEQ_ROH) { MIN_SEQ = Number.parseInt(MIN_SEQ_ROH, 10); // Laut ablehnen statt still verschlucken (Pentest R179-01): ein Tippfehler // ergab frueher `NaN`, wurde ignoriert – und weil die blosse PRAESENZ eines // Werts den Vorbehalt ausblendete, sah das Ergebnis anschliessend // selbstbewusster aus als ganz ohne Angabe. `0` war sogar ein Freibrief. if (!Number.isFinite(MIN_SEQ) || MIN_SEQ <= 0) { console.error( `NOTARY_MIN_SEQ ist kein sinnvoller Wert: "${MIN_SEQ_ROH}".\n` + 'Erwartet wird die zuletzt bekannte Checkpoint-Nummer als positive Zahl.\n' + 'Lieber weglassen als raten – dann sagt die Prüfung ehrlich, dass ein ' + 'Zurückspulen nicht erkennbar war.', ); process.exit(1); } } // Zusicherung des Betreibers, dass der Server non-fast-forward-Pushes // verbietet (Pentest R179-01/-b). // // NUR diese Zusicherung darf den Rewind-Vorbehalt ausblenden – nicht die // blosse Anwesenheit einer Zahl in NOTARY_MIN_SEQ. Der Grund: MIN_SEQ ist eine // UNTERGRENZE und immer nur so frisch wie die letzte Beobachtung. Wer stuendlich // beglaubigt, aber woechentlich prueft, laeuft mit einem Wert herum, der um // ~168 hinterherhinkt – jeder Rewind in dieses Fenster passiert lautlos, und // der gewaschene Stand wird sogar ausdruecklich bestaetigt. const REWIND_GESCHUETZT = process.env.NOTARY_REWIND_PROTECTED === 'true'; // Beobachtungsspeicher fuer den Fast-Forward-Monitor (Pentest R179 (a)). // // Statt die POLICY abzufragen (Probe-Push), wird das EREIGNIS erkannt, das die // Policy verhindern soll. Bei jedem Lauf wird der beobachtete Remote-Kopf // festgehalten; beim naechsten Mal muss der neue Kopf ein Nachfahre des alten // sein. Ist er das nicht, wurde zurueckgespult – unabhaengig davon, ob es einen // serverseitigen Schutz gibt oder nicht. // // Ein Probe-Push waere hier der falsche Weg: Er misst die eigene // Push-Identitaet, nicht die des Angreifers (Bypass-Rechte fuer Admins gehen // genau dann auseinander, wenn es darauf ankommt), er gilt nur fuer den // geprobten Ref, und die bestaetigende Beobachtung waere derselbe Vorgang wie // der Schaden. // // Die Datei gehoert bewusst NICHT in den Klon – sonst nimmt ein Neu-Klonen ihr // die Erinnerung. Idealerweise liegt sie auf separatem Speicher. const STATE_FILE = resolve( process.env.NOTARY_STATE_FILE || join(homedir(), '.opencrm-notary', 'beobachtungen.jsonl'), ); 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); } // stderr wird bewusst abgefangen statt durchgereicht: sonst stehen git-eigene // Fehlermeldungen VOR der Erklaerung dieses Skripts und der Betreiber liest // zuerst etwas Kryptisches. Relevante git-Ausgaben geben wir gezielt weiter. const GIT_STDIO = ['ignore', 'pipe', 'pipe']; const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: GIT_STDIO }).trim(); const gitStill = (...args) => { try { return { ok: true, out: git(...args) }; } catch (e) { return { ok: false, out: String(e.stderr || e.message).trim() }; } }; // Pin aus der lokalen Signierkonfiguration ableiten, wenn nicht vorgegeben. if (SIGN && !PIN) { const konf = gitStill('config', 'user.signingkey'); const kandidat = konf.ok ? konf.out.trim().replace(/^key::/, '') : ''; if (kandidat && existsSync(kandidat)) { try { // `ssh-keygen -lf` liefert z. B. "256 SHA256:abc… kommentar (ED25519)". // Gebraucht wird genau das mittlere Feld – dasselbe Format, das git als // %GF ausgibt. const zeile = execFileSync('ssh-keygen', ['-lf', kandidat], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); const treffer = zeile.split(/\s+/).find((t) => t.startsWith('SHA256:')); if (treffer) PIN = treffer; } catch { /* faellt unten in den Abbruch */ } } if (!PIN) { console.error( 'Der erwartete Signierschlüssel steht nicht fest.\n' + 'Ohne ihn wäre der Vertrauensanker die gesamte allowed_signers-Liste und nicht\n' + 'der eine Notar-Schlüssel – jeder dort gelistete Zweitschlüssel könnte das\n' + 'Gegenbuch umschreiben, ohne dass es auffällt.\n' + 'Abhilfe: NOTARY_SIGNER_FINGERPRINT setzen (git log -1 --format=%GF)\n' + 'oder user.signingkey auf die .pub-Datei des Notar-Schlüssels zeigen lassen.', ); process.exit(1); } } async function hole(pfad) { // Fehler werden hier zu einer erklaerenden Zeile – frueher flog ein // Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer // git-Meldungen schon abgestellt war. let r; try { r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } }); } catch (e) { console.error( `Das CRM ist nicht erreichbar (${CRM_URL}).\n` + ` Grund: ${e instanceof Error ? e.message : String(e)}\n` + 'Ohne aktuellen Kontrollwert kann nichts beglaubigt werden – das ist ein ' + 'Verfügbarkeitsproblem, kein Befund.', ); process.exit(1); } if (r.status === 401 || r.status === 403) { console.error( `Das CRM hat den Zugriff abgelehnt (HTTP ${r.status}).\n` + 'Meist ist der Token abgelaufen oder ihm fehlt das Recht audit:read.', ); process.exit(1); } if (!r.ok) { console.error(`Unerwartete Antwort des CRM: ${pfad} -> HTTP ${r.status}`); process.exit(1); } let j; try { j = await r.json(); } catch { console.error(`Antwort des CRM ist kein JSON (${pfad}) – steht ein Proxy oder eine Fehlerseite davor?`); process.exit(1); } if (!j.success) { console.error(`Das CRM meldet einen Fehler: ${j.error}`); process.exit(1); } return j.data; } // Lokaler Betrieb ist ein vollwertiger Modus, wenn das Gegenbuch auf einem // EIGENEN Rechner laeuft: Wer das CRM uebernimmt, kommt dann nicht an dieses // Buch. Was fehlt, ist der Schutz gegen jemanden, der DIESEN Rechner // uebernimmt – das gehoert benannt, aber es ist kein „kein Schutz“. const OHNE_ANKER = () => NUR_LOKAL ? '\n Betriebsart: lokal. Geschützt ist damit der Fall „jemand verändert Daten im ' + 'CRM“.\n NICHT geschützt ist der Fall „jemand übernimmt diesen Rechner hier“ – ' + 'dagegen hülfe\n nur eine zusätzliche Ablage außerhalb.' : ''; // Ohne bekannte Mindesthoehe bleibt ein Rewind auf einen aelteren, echt // signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung, // sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist. const REWIND_VORBEHALT = () => REWIND_GESCHUETZT || ffBelegt || NUR_LOKAL ? '' : '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' + 'lässt sich hier nicht ausschließen.' + (Number.isFinite(MIN_SEQ) ? `\n NOTARY_MIN_SEQ=${MIN_SEQ} deckt nur ab, was UNTERHALB dieser Nummer fehlt – ` + 'ein Rewind oberhalb bliebe unbemerkt, und der Wert hinkt naturgemäß hinterher.' : '\n Ohne NOTARY_MIN_SEQ gibt es dafür gar keinen Bezugspunkt.') + '\n Verlässlich hilft nur serverseitiger Rewind-Schutz (non-fast-forward verbieten);' + ' ist er eingerichtet, mit NOTARY_REWIND_PROTECTED=true bestätigen.'; // Rueckgabecode statt blosser Prosa (Pentest R179-b). // // Der Vorbehalt stand in stdout, der Exit blieb 0 – und Cron greppt den Code, // nicht den Text. Der Zustand „Rewind nicht ausschliessbar“ erreichte die // Automation damit nie. Code 0 gibt es deshalb nur noch bei belegter // Gewissheit: entweder ist serverseitiger Rewind-Schutz zugesichert, oder eine // mitgegebene Mindesthoehe wurde erfuellt. Sonst Code 3 – „nicht abschliessend // feststellbar“, dieselbe Bedeutung wie beim unerreichbaren Remote und // bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen. const abschliessendSicher = (anzahl) => REWIND_GESCHUETZT || // Im lokalen Betrieb gibt es keinen Remote, den jemand zurueckspulen koennte. // Der Vergleich gegen den eigenen Beobachtungsspeicher ist hier der // vollstaendige Nachweis – die Grenze liegt bei der Integritaet dieses // Rechners und ist als solche benannt, nicht ungeklaert. (NUR_LOKAL && ffBelegt) || // BEWUSST NICHT `ankerBelegt` (Pentest R180-01): Die Anker-Refs zeigen nur // den HOECHSTEN NOCH VORHANDENEN Stand – nicht den hoechsten je erreichten. // Wer die obersten Refs loescht, senkt damit den Vergleichswert selbst; ein // niedrigerer Anker bleibt stehen und sah bisher wie Bestaetigung aus. // Perverser Gradient: Wer ALLE Refs loescht, flog auf (Code 4); wer nur die // oberen loeschte, kam durch. Anker koennen ein Zurueckspulen also WIDERLEGEN // (siehe Alarm oben), aber nie Unversehrtheit BELEGEN. ffBelegt || // Belegte Fast-Forward-Kette seit der ersten Beobachtung: das ist ein // Nachweis, kein Versprechen – deshalb zaehlt er (R179 (a)). ffBelegt || (Number.isFinite(MIN_SEQ) && anzahl >= MIN_SEQ); const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); }; // --------------------------------------------------------------------------- // 1) Vertrauenswuerdigen Stand bestimmen: bevorzugt der Remote-Kopf. // --------------------------------------------------------------------------- // Auch der allererste git-Aufruf muss erklaerend scheitern koennen – sonst // steht bei einem kaputten Klon ein Node-Stacktrace statt einer Diagnose. const zweigErmittelt = gitStill('rev-parse', '--abbrev-ref', 'HEAD'); if (!zweigErmittelt.ok || !zweigErmittelt.out || zweigErmittelt.out === 'HEAD') { console.error( 'Kein ausgecheckter Branch gefunden – ist das hier wirklich ein Klon des Gegenbuchs?\n' + (zweigErmittelt.ok ? '' : ` git-Meldung: ${zweigErmittelt.out}`), ); process.exit(1); } const zweig = zweigErmittelt.out; let ref = 'HEAD'; const hatRemote = gitStill('remote', 'get-url', REMOTE).ok; if (hatRemote) { const f = gitStill('fetch', '--quiet', REMOTE); if (f.ok && gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) { ref = `${REMOTE}/${zweig}`; } else { // FAIL-CLOSED (Pentest R176-02): Frueher fiel der Ablauf hier still auf // `HEAD` zurueck – ausgerechnet unter der Bedingung, die einen vorherigen // Push-Fehlschlag verursacht hat. Ein nie gepushter Checkpoint galt dann // als beglaubigt, und `--check` meldete waehrend eines Remote-Ausfalls // gruenes Licht auf nicht notarisiertem Zustand. // // Ohne erreichbaren Remote laesst sich der beglaubigte Stand schlicht // nicht feststellen. Das ist kein Grund, dem lokalen Stand zu glauben. console.error( `Der beglaubigte Stand ist nicht feststellbar: ${REMOTE}/${zweig} nicht erreichbar.\n` + `Ohne Remote kann nicht entschieden werden, ob der lokale Stand jemals notarisiert wurde.\n` + `Das ist ein Verfügbarkeitsproblem – kein Freibrief. Erst Verbindung klären, dann erneut prüfen.\n` + (f.ok ? '' : ` git-Meldung: ${f.out}\n`), ); process.exit(3); } } else if (process.env.NOTARY_ALLOW_LOCAL === 'true') { // Ausdruecklich gewaehlter lokaler Betrieb (Gegenbuch auf eigenem Rechner, // ohne zusaetzliche Ablage ausserhalb). Die Erfolgsmeldung benennt, was das // abdeckt und was nicht – ein glattes „OK“ ohne Einordnung gibt es nicht. NUR_LOKAL = true; } else { console.error( `Kein Remote '${REMOTE}' konfiguriert. Ein Gegenbuch, das nur lokal liegt, schützt nichts –\n` + `es steht im selben Zugriffsbereich wie alles andere auf diesem Rechner.\n` + `Für einen bewussten lokalen Testlauf: NOTARY_ALLOW_LOCAL=true`, ); process.exit(3); } // 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 insoweit ` + `nicht beglaubigt.\n` + `ZUERST PRÜFEN, wie sie entstanden sind: ein abgebrochener Lauf hinterlässt so einen ` + `Commit ebenso wie ein falsch signierter (dann darf er NICHT gepusht werden, sonst ist ` + `die Kette dauerhaft rot).\n` + ` Ansehen mit: git log ${ref}..HEAD --format='%h %G? %GF %s'`, ); } } // --------------------------------------------------------------------------- // 1a) Anker-Refs: das Gedaechtnis liegt im Substrat, nicht auf diesem Rechner. // // Jeder verankerte Checkpoint bekommt zusaetzlich einen eigenen Ref // `refs/notary/seq-N`. Der ueberlebt einen Force-Push auf `main`, weil er ein // anderer Ref ist. Damit ist die hoechste je existierende Nummer aus dem // Remote rekonstruierbar – auch dann, wenn der lokale Beobachtungsspeicher // fehlt. // // Das schliesst die Luecke, die ein reiner Host-Speicher offenlaesst: Ein // Angreifer, der ihn loescht, brauchte sonst nur EIN „erste Beobachtung“ // zu ueberstehen – danach haette der Waechter den zurueckgespulten Stand als // Wahrheit adoptiert und dauerhaft gruen gemeldet. Ein gutartiger Default, den // ein Angreifer gezielt erzeugen kann, ist kein sicherer Default. // --------------------------------------------------------------------------- const ankerRefs = (() => { const r = gitStill('ls-remote', REMOTE, 'refs/notary/seq-*'); if (!r.ok || !r.out) return []; return r.out .split('\n') .map((z) => z.trim().split(/\s+/)) .filter((t) => t.length === 2) .map(([sha, name]) => ({ sha, seq: Number.parseInt(name.replace(/.*seq-/, ''), 10) })) .filter((e) => Number.isFinite(e.seq)) .sort((a, b) => a.seq - b.seq); })(); const hoechsterAnker = ankerRefs[ankerRefs.length - 1] || null; const remoteKopf = git('rev-parse', ref); let ankerBelegt = false; if (hoechsterAnker) { // Der beglaubigte Checkpoint muss im aktuellen Kopf noch enthalten sein. // Ist er es nicht, wurde `main` an ihm vorbei zurueckgespult – und das faellt // hier auf, ganz ohne lokalen Speicher. const drin = hoechsterAnker.sha === remoteKopf || gitStill('merge-base', '--is-ancestor', hoechsterAnker.sha, remoteKopf).ok; if (!drin) { alarm( `Der verankerte Checkpoint ${hoechsterAnker.seq} ist im aktuellen Stand nicht mehr ` + `enthalten – die Reihe wurde an ihm vorbei zurückgespult.\n` + ` verankert: ${hoechsterAnker.sha.slice(0, 12)} (refs/notary/seq-${hoechsterAnker.seq})\n` + ` jetzt : ${remoteKopf.slice(0, 12)}\n` + `Diese Refs überleben einen Force-Push auf ${zweig} – deshalb fällt es auf.`, ); } ankerBelegt = true; } const remoteKennung = gitStill('remote', 'get-url', REMOTE).out || zweig; function beobachtungenLesen() { if (!existsSync(STATE_FILE)) return []; return readFileSync(STATE_FILE, 'utf8') .split('\n') .filter((z) => z.trim()) .map((z) => { try { return JSON.parse(z); } catch { return null; } }) .filter((e) => e && e.remote === remoteKennung && e.zweig === zweig); } const beobachtungen = beobachtungenLesen(); const letzteBeobachtung = beobachtungen[beobachtungen.length - 1]; let ffBelegt = false; // --------------------------------------------------------------------------- // 1c) Fehlt das Waechter-Gedaechtnis – und ist das harmlos? // // „Kein Speicher“ ist zweierlei, und der Unterschied entscheidet alles: // * Es gab hier noch nie etwas → echte Erstinbetriebnahme, legitim. // * Es gibt bereits eine notarisierte Historie, aber kein Gedaechtnis mehr // → das Gedaechtnis ging NACH der Etablierung verloren. Das ist die // Anomalie, nicht der Normalfall – und genau der Zustand, den ein // Angreifer nach einem Rewind gezielt herstellt, um die naechste Baseline // auf seinen Stand zu setzen. // // Deshalb: kein stilles Neu-Baselinen. Eine Baseline zu setzen heisst „ich // verbuerge mich, DAS ist der wahre Kopf“ – dieser Akt gehoert bestaetigt. // --------------------------------------------------------------------------- // Nur der SCHREIBLAUF setzt eine Baseline – nur dort ist die Sperre noetig. // Ein Auditor mit frischem Klon hatte nie ein Gedaechtnis; ihm Code 4 // vorzuwerfen waere Laerm. Er bekommt stattdessen exit 3 mit Vorbehalt. // // Das Kurzschliessen ueber `ankerBelegt` ist entfallen (R180-01): ein stehen // gelassener niedriger Anker durfte die Sperre nicht aufheben. if (!letzteBeobachtung && !NUR_PRUEFEN) { const remoteHatHistorie = gitStill('show', `${ref}:${LEDGER_NAME}`).ok; if (remoteHatHistorie && process.env.NOTARY_ADOPT_ACK !== 'true') { console.error( 'Das Wächter-Gedächtnis fehlt, obwohl bereits eine notarisierte Historie existiert.\n' + 'Das ist KEIN Normalzustand: Entweder wurde dieser Rechner neu aufgesetzt, oder der\n' + 'Beobachtungsspeicher wurde entfernt – letzteres ist genau das, was nach einem\n' + 'Zurückspulen passieren müsste, damit der nächste Lauf den gewaschenen Stand als\n' + 'Wahrheit übernimmt.\n\n' + 'Ohne Bestätigung wird hier nichts als Ausgangspunkt akzeptiert. Bitte erst klären:\n' + ` * Stimmt der aktuelle Kopf ${remoteKopf.slice(0, 12)} mit dem überein, was zuletzt\n` + ' beglaubigt wurde? (Protokoll, Monitoring, zweite Kopie des Gegenbuchs)\n' + ' * Gibt es refs/notary/seq-* auf dem Server? Dann lässt sich der Stand daraus\n' + ' rekonstruieren, statt ihn zu glauben.\n\n' + 'Erst wenn das geklärt ist: NOTARY_ADOPT_ACK=true setzen.', ); process.exit(4); } if (!remoteHatHistorie && process.env.NOTARY_GENESIS_ACK !== 'true') { console.error( 'Erstinbetriebnahme: Es gibt weder ein Wächter-Gedächtnis noch eine notarisierte\n' + 'Historie. Das ist plausibel – aber die erste Baseline ist ein Vertrauensakt und\n' + 'wird deshalb einmalig bestätigt:\n' + ' NOTARY_GENESIS_ACK=true', ); process.exit(4); } } if (letzteBeobachtung) { if (letzteBeobachtung.sha === remoteKopf) { ffBelegt = true; } else { // Ist der zuletzt gesehene Kopf ein Vorfahre des jetzigen? Dann ist die // Kette gewachsen. Sonst wurde sie zurueckgespult oder ersetzt. const vorfahre = gitStill('merge-base', '--is-ancestor', letzteBeobachtung.sha, remoteKopf); if (vorfahre.ok) { ffBelegt = true; } else { alarm( `Der Remote-Kopf ist KEIN Nachfahre des zuletzt beobachteten Standes – ` + `die Reihe wurde zurückgespult oder ersetzt.\n` + ` zuletzt gesehen: ${letzteBeobachtung.sha.slice(0, 12)} (${letzteBeobachtung.ts})\n` + ` jetzt : ${remoteKopf.slice(0, 12)}\n` + `Genau das soll der serverseitige Rewind-Schutz verhindern. Prüfen, wer ` + `force-pushen konnte – und ob die Datenbank dazu passend gekürzt wurde.`, ); } } } const beobachtungMerken = () => { try { mkdirSync(dirname(STATE_FILE), { recursive: true }); appendFileSync( STATE_FILE, JSON.stringify({ ts: new Date().toISOString(), remote: remoteKennung, zweig, sha: git('rev-parse', ref), }) + '\n', ); } catch (e) { console.warn( `HINWEIS: Beobachtung konnte nicht festgehalten werden (${STATE_FILE}).\n` + ` Ohne sie erkennt der nächste Lauf ein Zurückspulen nicht. Grund: ` + `${e instanceof Error ? e.message : String(e)}`, ); } }; // Fehlende Anker nachziehen (Pentest R180-02). // // Scheitert der Anker-Push, stand `main` schon auf dem neuen Checkpoint, // waehrend der Anker fehlte – und niemand holte das nach. Der Normalbetrieb // senkte damit den Substrat-Hoechststand still um eins und riss genau das // Fenster auf, das R180-01 ausnutzt. Deshalb wird vor allem anderen // nachgezogen, was fehlt. let fehlendeAnker = []; function ankerNachziehen(bisherLaenge) { const vorhanden = new Set(ankerRefs.map((a) => a.seq)); fehlendeAnker = []; for (let n = 1; n <= bisherLaenge; n++) if (!vorhanden.has(n)) fehlendeAnker.push(n); if (!fehlendeAnker.length) return; // Im PRUEFMODUS wird NICHT nachgezogen (Pentest R181-01/-02). // // Zwei Gruende, und beide sind grundsaetzlich: // 1. Der Pruefmodus sagt zu, nichts zu veraendern und kein Schreibrecht zu // brauchen. Ein Audit, das dabei still das Substrat mutiert, bricht // genau diese Zusage. // 2. `refs/notary/*` MUSS serverseitig gegen Schreibzugriff gesperrt sein // (R180-01) – dieselbe Sperre lehnt den Nachtrag ab. Ein read-only // pruefender Auditor bekaeme dadurch dauerhaft einen Fehler auf einer // voellig gueltigen Kette, den er gar nicht beheben kann. // // Der fehlende Anker wird gemeldet, nicht repariert. Reparieren ist Sache // des Notar-Schreiblaufs, dessen Identitaet in der Serversperre eine // Ausnahme fuer das ANLEGEN von `refs/notary/*` braucht (nicht fuers // Loeschen oder Ueberschreiben – sonst hebelt eine kompromittierte // Notar-Identitaet den Schutz aus R180-01 selbst aus). if (NUR_PRUEFEN || NUR_LOKAL || !PUSH) return; // Die n-te Aenderung am Gegenbuch entspricht Checkpoint n. const commits = gitStill('log', '--reverse', '--format=%H', ref, '--', LEDGER_NAME); const liste = commits.ok ? commits.out.split('\n').filter(Boolean) : []; let nachgezogen = 0; const nachgetragen = new Set(); for (const n of fehlendeAnker) { const sha = liste[n - 1]; if (!sha) continue; if (gitStill('push', REMOTE, `${sha}:refs/notary/seq-${n}`).ok) { nachgezogen++; nachgetragen.add(n); } } if (nachgezogen) console.log(`Fehlende Anker nachgetragen: ${nachgezogen}`); fehlendeAnker = fehlendeAnker.filter((n) => !nachgetragen.has(n)); if (fehlendeAnker.length) { console.error( `${fehlendeAnker.length} Anker-Ref(s) fehlen und ließen sich nicht nachtragen: ` + `${fehlendeAnker.join(', ')}\n` + 'Ohne sie ist der beglaubigte Höchststand nicht aus dem Server rekonstruierbar –\n' + 'genau die Eigenschaft, auf der dieser Schutz beruht.\n' + 'Wahrscheinlichste Ursache: Die Serversperre für refs/notary/* lässt auch die\n' + 'Notar-Identität nicht anlegen. Sie braucht dort eine Ausnahme zum ANLEGEN –\n' + 'ausdrücklich nicht zum Löschen oder Überschreiben.', ); process.exit(5); } } // --------------------------------------------------------------------------- // 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) { // NUR `G` (Pentest R176-01). Bei SSH-Signaturen bedeutet `U` woertlich // „gute Signatur, aber kein passender Principal“ – also: der Schluessel // steht NICHT in `allowed_signers`. Wer `U` akzeptiert, akzeptiert jeden // selbst erzeugten Schluessel und macht damit genau den Vertrauensanker // wirkungslos, den er zu pruefen glaubt. Live gezeigt: mit einem fremden // Schluessel re-signiertes, force-gepushtes Gegenbuch lief als „gültig“ // durch. // // Hinweis fuer GPG statt SSH: dort liefert ein gueltiger, aber nicht // ownertrusted Schluessel ebenfalls `U`. Dann Ownertrust auf den // Notar-Schluessel setzen – NICHT `U` wieder zulassen. const schlecht = zeilen .map((z) => z.split(' ')) .filter(([, status]) => status !== 'G'); // 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); }); // Optional schaerfer: den erwarteten Signierschluessel festnageln, statt sich // allein auf die Bewertung von git zu verlassen. if (PIN) { const fremde = zeilen .map(([, ] , i) => i) .map((i) => zeilen[i].split(' ')[0]) .filter((sha) => { const dateien = gitStill('show', '--name-only', '--format=', sha); if (!dateien.ok || !dateien.out.includes(LEDGER_NAME)) return false; const fp = gitStill('log', '-1', '--format=%GF', sha); return !fp.ok || fp.out.trim() !== PIN; }); if (fremde.length) { alarm( `${fremde.length} Commit(s) wurden mit einem anderen als dem festgelegten ` + `Schlüssel signiert (erwartet: ${PIN}):\n ` + fremde.map((sha) => sha.slice(0, 12)).join('\n '), ); } } 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}).`); } } ankerNachziehen(bisher.length); if (hoechsterAnker && bisher.length < hoechsterAnker.seq) { alarm( `Die Checkpoint-Reihe ist kürzer als der Server beglaubigt hat: ` + `verankert bis ${hoechsterAnker.seq}, vorhanden ${bisher.length}.\n` + `Die vorhandenen Einträge sind korrekt signiert – es fehlen welche am Ende.`, ); } if (Number.isFinite(MIN_SEQ) && bisher.length < MIN_SEQ) { alarm( `Die Checkpoint-Reihe ist kürzer als bekannt: erwartet mindestens ${MIN_SEQ}, ` + `vorhanden ${bisher.length}.\n` + `Die vorhandenen Einträge sind zwar korrekt signiert – aber es fehlen welche am Ende. ` + `Das ist das Bild eines Rewinds per Force-Push.`, ); } // --------------------------------------------------------------------------- // 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) { const sicher = abschliessendSicher(bisher.length); // Eigener Code fuer den fehlenden Anker (Pentest R181-02, Frage b). // // „Anker fehlt“ ist NICHT dasselbe wie „nicht feststellbar“: Die Kette ist // geprueft und gueltig, nur das Substrat-Gedaechtnis ist unvollstaendig – // ein bekannter, benannter, durch einen Notar-Lauf reparierbarer Defekt. // Ihn in Code 3 zu werfen hiesse, eine konkrete Handlungsanweisung als // Ungewissheit zu melden; dieselbe Trennung wie bei Genesis/Adoption. if (fehlendeAnker.length) { console.log( `Anker unvollständig: refs/notary/seq-${fehlendeAnker.join(', seq-')} fehlt/fehlen.\n` + ' Die Kette selbst ist geprüft und gültig – aber der beglaubigte Höchststand ist\n' + ' ohne diese Refs nicht vollständig aus dem Server rekonstruierbar.\n' + ' Behebbar durch einen Notar-Schreiblauf; im Prüfmodus wird bewusst nichts\n' + ' gepusht (dieser Modus verändert nichts und braucht kein Schreibrecht).', ); } 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}).` : '.') + OHNE_ANKER() + REWIND_VORBEHALT(), ); beobachtungMerken(); if (fehlendeAnker.length) process.exit(5); process.exit(sicher ? 0 : 3); } // --------------------------------------------------------------------------- // 5) Tor VOR dem Anhaengen (Pentest R179 (b)). // // Der Schreiblauf erweitert nicht nur die Kette – er signiert damit zugleich // ueber den Bestand darunter. Ist die Basis nicht feststellbar, waere das // Anhaengen selbst das Waschmittel: eine frische Signatur ueber einen // ungeklaerten Vorzustand beglaubigt alles darunter mit. // // Ausnahme ist der allererste Lauf: Was man nie gesehen hat, kann man nicht // verifizieren. Diese Basislinie wird gesetzt, aber ehrlich als solche // gemeldet – nicht als gruenes Licht. // --------------------------------------------------------------------------- const basislinie = !letzteBeobachtung; if (!abschliessendSicher(bisher.length) && !basislinie) { console.error( 'Die Grundlage für einen neuen Checkpoint ist nicht abschließend feststellbar –\n' + 'es wird nichts angehängt. Ein Checkpoint über einen ungeklärten Vorzustand würde\n' + 'diesen mitbeglaubigen.' + REWIND_VORBEHALT(), ); process.exit(3); } 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') { git('reset', '--hard', 'HEAD~1'); alarm(`Der neue Commit trägt keine gültige Signatur (%G?=${st}). Zurückgerollt.`); } // Auch der FRISCHE Commit muss vom festgelegten Schluessel stammen // (Pentest R177-02). Vorher wurde hier nur `%G?` geprueft: Ein Notar-Host // mit falsch konfiguriertem Schluessel meldete „beglaubigt“ und pushte – // und ab da war die Kette bei jeder Folgepruefung dauerhaft rot, behebbar // nur per Force-Push, den die Branch-Protection gerade verhindern soll. // Deshalb: pruefen und zuruecknehmen, BEVOR gepusht wird. if (PIN) { const fp = git('log', '-1', '--format=%GF', 'HEAD').trim(); if (fp !== PIN) { git('reset', '--hard', 'HEAD~1'); alarm( `Der neue Commit wurde mit einem anderen Schlüssel signiert als festgelegt.\n` + ` erwartet: ${PIN}\n verwendet: ${fp}\n` + `Zurückgerollt, nichts gepusht – bitte user.signingkey auf dem Notar-Rechner prüfen.`, ); } } } if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert – das Gegenbuch ist ungeschützt.'); let verankert = false; if (PUSH && !NUR_LOKAL) { const p = gitStill('push', REMOTE, zweig); if (!p.ok) { // NICHT exit 0 und nicht exit 1: Der Checkpoint wurde zwar erstellt, ist // aber nicht verankert. „Mechanisch erweitert“ ist nicht „nachweislich // verankert“ – diese Verwechslung waere der Waschgang (R179 (b)). console.error( `Checkpoint ${eintrag.seq} wurde erstellt, aber NICHT verankert – der Push ist ` + `fehlgeschlagen.\n` + `Er liegt nur lokal und zählt nicht als beglaubigt. Der nächste Lauf wird ihn als ` + `"nie gepusht" melden, bis er verankert ist.\n git-Meldung: ${p.out}`, ); process.exit(3); } // Eigener, unveraenderlicher Ref je Checkpoint. Er ueberlebt einen // Force-Push auf den Zweig und macht das Gedaechtnis wiederherstellbar, // statt es an diesen Rechner zu binden. const ankerRef = `refs/notary/seq-${eintrag.seq}`; const a = gitStill('push', REMOTE, `HEAD:${ankerRef}`); if (!a.ok) { // Kein blosser Hinweis mehr (R180-02): Der Anker IST die tragende // Eigenschaft. Fehlt er, ist der Checkpoint zwar im Zweig, aber der // Hoechststand nicht mehr aus dem Server rekonstruierbar – und der // naechste Auditor saehe einen um eins zu niedrigen Stand als vollstaendig. console.error( `Checkpoint ${eintrag.seq} ist gepusht, aber der Anker ${ankerRef} konnte NICHT ` + `gesetzt werden.\n` + `Damit ist der beglaubigte Höchststand nicht mehr aus dem Server rekonstruierbar. ` + `Der nächste Lauf versucht, ihn nachzutragen – bis dahin gilt der Stand als nicht ` + `vollständig verankert.\n git-Meldung: ${a.out}`, ); process.exit(3); } verankert = true; beobachtungMerken(); } else if (NUR_LOKAL) { beobachtungMerken(); } const basisHinweis = basislinie ? '\n Erste Beobachtung dieses Gegenbuchs – ab jetzt fällt ein Zurückspulen auf. ' + 'Für diesen Lauf selbst gibt es noch nichts zu vergleichen.' : ''; console.log( `${verankert || NUR_LOKAL ? 'OK' : 'HINWEIS'}: Checkpoint ${eintrag.seq} ` + `${verankert ? 'beglaubigt' : 'erstellt'} (maxId ${eintrag.maxId}).` + OHNE_ANKER() + REWIND_VORBEHALT() + basisHinweis + `\n Zuletzt bekannte Nummer für die nächste Prüfung: NOTARY_MIN_SEQ=${eintrag.seq}`, ); process.exit(!basislinie && abschliessendSicher(eintrag.seq) ? 0 : 3);