Files
opencrm/tools/audit-notary/notary.mjs
T
duffyduckandClaude Opus 5 b6b6f7c0a7 Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (R179 a/b)
(a) Probe-Push verworfen. Er haette nur den geprobten Ref beurteilt, die eigene
Push-Identitaet gemessen statt die des Angreifers (Bypass-Rechte fuer Admins
gehen genau dann auseinander, wenn es zaehlt), nur einen Zeitpunkt abgedeckt -
und einen zerstoerungsfreien Force-Push gibt es nicht: die bestaetigende
Beobachtung waere derselbe Vorgang wie der Schaden.

Stattdessen ein Fast-Forward-Waechter: Der beobachtete Remote-Kopf wird
ausserhalb des Klons festgehalten; beim naechsten Lauf muss der neue Kopf ein
Nachfahre des alten sein. Das erkennt das Ereignis statt die Regel abzufragen
und wirkt unabhaengig von serverseitigem Schutz. Ein belegter Fast-Forward
gilt als Nachweis und blendet den Rewind-Vorbehalt aus.

(b) Code 3 als Tor vor dem Anhaengen statt als Status danach: Der Schreiblauf
signiert mit dem neuen Checkpoint zugleich ueber den Bestand darunter - ist die
Basis ungeklaert, waere das Anhaengen selbst das Waschmittel. Grundlage nicht
feststellbar -> nichts anhaengen, exit 3. Erster Lauf -> Basislinie, ehrlich
gemeldet, exit 3. Anhaengen geklappt, Push gescheitert -> exit 3 mit "erstellt,
aber NICHT verankert". Nur Anhaengen + Push + belegte Verankerung -> exit 0.

Verifiziert: Basislinie exit 3; Folgelauf exit 0 ohne Vorbehalt; Rewind aus
einem frischen Auditoren-Klon ohne MIN_SEQ und ohne Zusicherung -> Alarm exit 2
(bisher stilles Gruen); kaputtes Push-Ziel -> "erstellt, aber nicht verankert"
exit 3, Folgelauf haelt den ungepushten Commit fail-closed an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:29:20 +02:00

644 lines
28 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 || 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 ||
// 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'`,
);
}
}
// ---------------------------------------------------------------------------
// 1b) Fast-Forward-Monitor: wurde der Remote-Kopf zurueckgespult?
// ---------------------------------------------------------------------------
const remoteKopf = git('rev-parse', ref);
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;
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 (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);
}
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);