R181-01: Der Pruefmodus sagte zu, nichts zu veraendern und kein Schreibrecht zu brauchen - und pushte trotzdem, weil ankerNachziehen() in jedem Modus lief. Ein read-only Audit mutierte damit still das geteilte Substrat. R181-02: Ein Widerspruch zwischen den eigenen Fixes. R180-01 erhebt die Serversperre auf refs/notary/* zur tragenden Pflicht, R180-02 verlangt dort Schreibrecht zur Selbstheilung. Sobald je ein Anker fehlte, bekam jeder read-only pruefende Auditor dauerhaft einen Fehler auf einer voellig gueltigen Kette, den er nicht beheben konnte. Fix: Reparieren nur im Notar-Schreiblauf, im Pruefmodus wird der fehlende Anker gemeldet. Eigener Rueckgabecode 5: "Anker unvollstaendig" ist nicht "nicht feststellbar" - die Kette ist gueltig, nur das Substrat-Gedaechtnis unvollstaendig, ein benannter reparierbarer Defekt. Dieselbe Trennung wie bei Genesis/Adoption. Empirisch beantwortet: Die Notar-Identitaet laesst sich eng auf das Anlegen von refs/notary/* beschraenken, ohne Loeschen oder Ueberschreiben - serverseitig unterscheidbar an der Null-OID. Mit pre-receive-Hook verifiziert: Backfill greift, Loeschen und Force-Overwrite bleiben abgewiesen. Hook als Beispiel in der README. Verifiziert: read-only --check mit fehlendem Anker -> Code 5, nichts gepusht; Notar-Schreiblauf unter derselben ACL -> Anker nachgetragen, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
854 lines
38 KiB
JavaScript
Executable File
854 lines
38 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 || 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 ||
|
||
// 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') {
|
||
// 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.
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|