"Erste Beobachtung -> exit 3" galt genau einen Lauf. Danach baselinete der Waechter auf den aktuellen Kopf - nach einem Rewind also auf den gewaschenen Stand - und meldete dauerhaft gruen. Der Angreifer musste nur ein einziges exit 3 ueberstehen, ausgerechnet den Code, den der Betreiber bei Remote-Ausfaellen ohnehin staendig sieht. Damit war der Waechter, der Rewind ohne Notar-Host-Integritaet fangen sollte, wieder an genau diese gekoppelt. Fix: Jeder verankerte Checkpoint bekommt einen eigenen Ref refs/notary/seq-N. Der ueberlebt einen Force-Push auf den Zweig - die hoechste je existierende Nummer ist damit aus dem Server rekonstruierbar. Geprueft wird, ob der hoechste verankerte Checkpoint noch im aktuellen Kopf enthalten ist und ob die Reihe mindestens so lang ist wie verankert. Laute Verlustbehandlung mit Diskriminator "traegt der Remote schon Checkpoints?": keine Historie -> Genesis, einmalig NOTARY_GENESIS_ACK; Historie vorhanden aber kein Gedaechtnis -> Anomalie, Code 4, keine stille Adoption, erst nach NOTARY_ADOPT_ACK. Verifiziert: Genesis ohne Bestaetigung -> Code 4; mit Bestaetigung Kette aufgebaut samt refs/notary/seq-1..5; Rewind -> exit 2 auch nach Loeschen des lokalen Speichers und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft gruen); zusaetzlich Anker-Refs geloescht -> Code 4 statt stiller Uebernahme. Dokumentiert: Die Anker-Refs muessen serverseitig ebenfalls vor Loeschen und Ueberschreiben geschuetzt sein, sonst verschiebt sich das Problem eine Ebene. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
756 lines
33 KiB
JavaScript
Executable File
756 lines
33 KiB
JavaScript
Executable File
#!/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;
|
||
}
|
||
|
||
const OHNE_ANKER = () =>
|
||
NUR_LOKAL ? ' – ACHTUNG: nur lokal, kein externer Anker, also kein Manipulationsschutz.' : '';
|
||
|
||
// 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 || ankerBelegt || ffBelegt
|
||
? ''
|
||
: '\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 ||
|
||
// Verankerte Refs sind das belastbarste Signal: sie liegen im Substrat und
|
||
// ueberleben einen Force-Push auf den Zweig.
|
||
ankerBelegt ||
|
||
// 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') {
|
||
// Der Schalter darf nicht zu einem glatten „OK“ fuehren: Leckt er je ins
|
||
// Produktivumfeld, gaebe es gruenes Licht auf nicht notarisiertem Stand.
|
||
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.
|
||
// ---------------------------------------------------------------------------
|
||
if (!letzteBeobachtung && !ankerBelegt) {
|
||
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)}`,
|
||
);
|
||
}
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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}).`);
|
||
}
|
||
}
|
||
|
||
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);
|
||
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();
|
||
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) {
|
||
console.warn(
|
||
`HINWEIS: Anker-Ref ${ankerRef} konnte nicht gesetzt werden.\n` +
|
||
` Der Checkpoint ist gepusht, aber ohne diesen Ref bleibt das Gedächtnis an\n` +
|
||
` diesen Rechner gebunden – geht es verloren, ist der Stand nicht mehr aus dem\n` +
|
||
` Server rekonstruierbar. Grund: ${a.out}`,
|
||
);
|
||
}
|
||
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);
|