Files
duffyduckandClaude Opus 5 2ca6ed2f70 Neuberechnung der Kette als eigene Gegenbuch-Alarmbedingung (R186 Frage b)
Der Tester fragte praezise: Loest cleanup -> rehash OHNE erneutes Siegeln,
ueber NICHT versiegelten Inhalt, etwas Automatisches aus - oder nur die
Prosa in verify, die ein Mensch lesen muss?

Gemessen statt behauptet: Es loeste bereits aus, aber als Nebenwirkung.
Ein Rehash aendert jeden Hash, also stimmt der beglaubigte Kettenkopf
nicht mehr und der bestehende Vergleich schlug an. Das funktioniert, ist
aber ein Zufallstreffer - verschoebe sich der Anker, waere der Melder
lautlos weg. Und die Meldung hiess "Eintrag wurde veraendert" statt "die
Kette wurde neu berechnet", also Wirkung statt Ursache.

Jetzt haengt der Alarm an der Sache selbst: Das Gegenbuch fuehrt
rehashCount/rehashLast im Buch mit und meldet jede neue Neuberechnung
seit der letzten Beglaubigung mit exit 2, samt Zeitpunkt, Zeilenzahl und
dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01 (Gate
am Ausloeser statt an der Wirkung) und R185-01 (Wurzelwechsel statt
valid).

Reihenfolge geaendert, und das war noetig: Der neue Melder steht VOR dem
Kopf-Hash-Vergleich, sonst haette immer die unpraezisere Meldung
gewonnen. Und der Kopf-Vergleich wird nach einer bestaetigten
Neuberechnung uebersprungen - sonst waere die Bestaetigung wertlos, weil
ein Rehash den Kopf zwangslaeufig aendert. Beim Bauen aufgefallen, nicht
im Entwurf.

NOTARY_REHASH_ACK wird mit der ID des Rehash-Eintrags bestaetigt, nicht
mit true; IDs steigen streng, ein stehen gelassener Wert passt beim
naechsten Vorgang nicht mehr. Ein fehlender beglaubigter Eintrag
alarmiert weiterhin immer: bestaetigt wird die Neuberechnung, nicht das
Verschwinden von Zeilen.

Getestet mit echtem Gegenbuch gegen ein echtes CRM, SSH-signiertes
lokales Buch: Waesche ohne Datenbankzugriff -> CRM meldet valid:true und
chainGaps:[], Gegenbuch exit 2 mit der Neuberechnung als Ursache. Falsche
Ack-ID weiter exit 2, richtige exit 0, Folgelauf ruhig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:30:44 +02:00

1107 lines
51 KiB
JavaScript
Executable File
Raw Permalink 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);
}
const CRM_EMAIL = process.env.CRM_EMAIL;
const CRM_PASSWORD = process.env.CRM_PASSWORD;
// Bestaetigung fuer einen Wechsel der Siegelwurzel (Pentest R185-01).
//
// Ein Wechsel loest Alarm aus und der Alarm bricht ab, BEVOR der neue Stand
// ins Gegenbuch kommt. Ohne Bestaetigungsweg wuerde deshalb auch ein voellig
// legitimes Siegeln von da an bei jedem Lauf erneut alarmieren, ohne dass der
// Betreiber es je aufloesen koennte. Genau daran stirbt eine Warnung
// (dieselbe Lehre wie R183-03).
//
// Bestaetigt wird deshalb nicht pauschal mit „true“, sondern mit der WURZEL,
// die man akzeptiert mindestens 16 Zeichen. Ein versehentlich stehen
// gelassener Wert passt beim naechsten Wechsel nicht mehr und kann darum
// keinen weiteren Austausch stillschweigend durchwinken. Das ist der
// Unterschied zu NOTARY_GENESIS_ACK, wo genau diese Falle dokumentiert
// werden musste.
const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim();
const siegelBestaetigt = (wurzel) =>
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
// Bestaetigung fuer eine Neuberechnung der Kette (Pentest R186, Frage (b)).
//
// Bestaetigt wird mit der ID des juengsten Rehash-Eintrags. IDs steigen
// streng, ein stehen gelassener Wert passt also beim naechsten Rehash nicht
// mehr - dasselbe selbstentwertende Prinzip wie bei NOTARY_SEAL_ACK, nur mit
// einem Wert, den man vorlesen kann.
const REHASH_ACK = (process.env.NOTARY_REHASH_ACK || '').trim();
// Wurde in diesem Lauf eine Neuberechnung ausdruecklich bestaetigt? Dann ist
// der veraenderte Kettenkopf die erwartete Folge und kein eigener Befund.
let rehashBestaetigt = false;
if (!CRM_URL) {
console.error('CRM_URL muss gesetzt sein.');
process.exit(1);
}
if (!CRM_TOKEN && !(CRM_EMAIL && CRM_PASSWORD)) {
console.error(
'Es fehlt der Zugang zum CRM.\n' +
'Entweder CRM_EMAIL und CRM_PASSWORD eines Dienstkontos setzen (empfohlen \n' +
'das Gegenbuch meldet sich dann bei jedem Lauf selbst an), oder ein bereits\n' +
'vorhandenes CRM_TOKEN mitgeben (läuft nach 15 Minuten ab, nur für Tests).',
);
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);
}
}
// Access-Tokens leben nur 15 Minuten ein dauerhaft hinterlegtes Token waere
// beim naechsten stuendlichen Lauf laengst abgelaufen. Deshalb meldet sich das
// Gegenbuch mit einem eigenen Dienstkonto an und holt sich pro Lauf ein
// frisches. Das Konto braucht ausschliesslich die Berechtigung `audit:read`.
let zugangsToken = CRM_TOKEN || null;
async function anmelden() {
let r;
try {
r = await fetch(`${CRM_URL}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: CRM_EMAIL, password: CRM_PASSWORD }),
});
} catch (e) {
console.error(
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
` Grund: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
if (r.status === 401) {
console.error(
'Anmeldung am CRM abgelehnt E-Mail oder Passwort des Dienstkontos stimmen nicht.',
);
process.exit(1);
}
if (r.status === 429) {
console.error('Das CRM hat die Anmeldung wegen zu vieler Versuche gebremst. Später erneut.');
process.exit(1);
}
if (!r.ok) {
console.error(`Anmeldung am CRM fehlgeschlagen: HTTP ${r.status}`);
process.exit(1);
}
const j = await r.json();
if (!j.success || !j.data?.token) {
console.error(`Anmeldung am CRM fehlgeschlagen: ${j.error || 'kein Token in der Antwort'}`);
process.exit(1);
}
zugangsToken = j.data.token;
}
async function hole(pfad, methode = 'GET') {
// 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}`, {
method: methode,
headers: {
Authorization: `Bearer ${zugangsToken}`,
...(methode === 'POST' ? { 'content-type': 'application/json' } : {}),
},
...(methode === 'POST' ? { body: '{}' } : {}),
});
} catch (e) {
console.error(
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
` Grund: ${e instanceof Error ? e.message : String(e)}\n` +
'Ohne aktuellen Kontrollwert kann nichts beglaubigt werden das ist ein ' +
'Verfügbarkeitsproblem, kein Befund.',
);
process.exit(1);
}
if (r.status === 401 || r.status === 403) {
console.error(
`Das CRM hat den Zugriff abgelehnt (HTTP ${r.status}).\n` +
'Meist ist der Token abgelaufen oder ihm fehlt das Recht audit:read.',
);
process.exit(1);
}
if (!r.ok) {
console.error(`Unerwartete Antwort des CRM: ${pfad} -> HTTP ${r.status}`);
process.exit(1);
}
let j;
try {
j = await r.json();
} catch {
console.error(`Antwort des CRM ist kein JSON (${pfad}) steht ein Proxy oder eine Fehlerseite davor?`);
process.exit(1);
}
if (!j.success) {
console.error(`Das CRM meldet einen Fehler: ${j.error}`);
process.exit(1);
}
return j.data;
}
// Lokaler Betrieb ist ein vollwertiger Modus, wenn das Gegenbuch auf einem
// EIGENEN Rechner laeuft: Wer das CRM uebernimmt, kommt dann nicht an dieses
// Buch. Was fehlt, ist der Schutz gegen jemanden, der DIESEN Rechner
// uebernimmt das gehoert benannt, aber es ist kein „kein Schutz“.
const OHNE_ANKER = () =>
NUR_LOKAL
? '\n Betriebsart: lokal. Geschützt ist damit der Fall „jemand verändert Daten im ' +
'CRM“.\n NICHT geschützt ist der Fall „jemand übernimmt diesen Rechner hier“ ' +
'dagegen hülfe\n nur eine zusätzliche Ablage außerhalb.'
: '';
// Ohne bekannte Mindesthoehe bleibt ein Rewind auf einen aelteren, echt
// signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung,
// sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist.
const REWIND_VORBEHALT = () =>
REWIND_GESCHUETZT || ffBelegt || NUR_LOKAL
? ''
: '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' +
'lässt sich hier nicht ausschließen.' +
(Number.isFinite(MIN_SEQ)
? `\n NOTARY_MIN_SEQ=${MIN_SEQ} deckt nur ab, was UNTERHALB dieser Nummer fehlt ` +
'ein Rewind oberhalb bliebe unbemerkt, und der Wert hinkt naturgemäß hinterher.'
: '\n Ohne NOTARY_MIN_SEQ gibt es dafür gar keinen Bezugspunkt.') +
'\n Verlässlich hilft nur serverseitiger Rewind-Schutz (non-fast-forward verbieten);' +
' ist er eingerichtet, mit NOTARY_REWIND_PROTECTED=true bestätigen.';
// Rueckgabecode statt blosser Prosa (Pentest R179-b).
//
// Der Vorbehalt stand in stdout, der Exit blieb 0 und Cron greppt den Code,
// nicht den Text. Der Zustand „Rewind nicht ausschliessbar“ erreichte die
// Automation damit nie. Code 0 gibt es deshalb nur noch bei belegter
// Gewissheit: entweder ist serverseitiger Rewind-Schutz zugesichert, oder eine
// mitgegebene Mindesthoehe wurde erfuellt. Sonst Code 3 „nicht abschliessend
// feststellbar“, dieselbe Bedeutung wie beim unerreichbaren Remote und
// bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
const abschliessendSicher = (anzahl) =>
REWIND_GESCHUETZT ||
// Im lokalen Betrieb gibt es keinen Remote, den jemand zurueckspulen koennte.
// Der Vergleich gegen den eigenen Beobachtungsspeicher ist hier der
// vollstaendige Nachweis die Grenze liegt bei der Integritaet dieses
// Rechners und ist als solche benannt, nicht ungeklaert.
(NUR_LOKAL && ffBelegt) ||
// BEWUSST NICHT `ankerBelegt` (Pentest R180-01): Die Anker-Refs zeigen nur
// den HOECHSTEN NOCH VORHANDENEN Stand nicht den hoechsten je erreichten.
// Wer die obersten Refs loescht, senkt damit den Vergleichswert selbst; ein
// niedrigerer Anker bleibt stehen und sah bisher wie Bestaetigung aus.
// Perverser Gradient: Wer ALLE Refs loescht, flog auf (Code 4); wer nur die
// oberen loeschte, kam durch. Anker koennen ein Zurueckspulen also WIDERLEGEN
// (siehe Alarm oben), aber nie Unversehrtheit BELEGEN.
ffBelegt ||
// Belegte Fast-Forward-Kette seit der ersten Beobachtung: das ist ein
// Nachweis, kein Versprechen deshalb zaehlt er (R179 (a)).
ffBelegt ||
(Number.isFinite(MIN_SEQ) && anzahl >= MIN_SEQ);
const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); };
// ---------------------------------------------------------------------------
// 1) Vertrauenswuerdigen Stand bestimmen: bevorzugt der Remote-Kopf.
// ---------------------------------------------------------------------------
// Auch der allererste git-Aufruf muss erklaerend scheitern koennen sonst
// steht bei einem kaputten Klon ein Node-Stacktrace statt einer Diagnose.
const zweigErmittelt = gitStill('rev-parse', '--abbrev-ref', 'HEAD');
if (!zweigErmittelt.ok || !zweigErmittelt.out || zweigErmittelt.out === 'HEAD') {
console.error(
'Kein ausgecheckter Branch gefunden ist das hier wirklich ein Klon des Gegenbuchs?\n' +
(zweigErmittelt.ok ? '' : ` git-Meldung: ${zweigErmittelt.out}`),
);
process.exit(1);
}
const zweig = zweigErmittelt.out;
let ref = 'HEAD';
const hatRemote = gitStill('remote', 'get-url', REMOTE).ok;
if (hatRemote) {
const f = gitStill('fetch', '--quiet', REMOTE);
if (f.ok && gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) {
ref = `${REMOTE}/${zweig}`;
} else {
// FAIL-CLOSED (Pentest R176-02): Frueher fiel der Ablauf hier still auf
// `HEAD` zurueck ausgerechnet unter der Bedingung, die einen vorherigen
// Push-Fehlschlag verursacht hat. Ein nie gepushter Checkpoint galt dann
// als beglaubigt, und `--check` meldete waehrend eines Remote-Ausfalls
// gruenes Licht auf nicht notarisiertem Zustand.
//
// Ohne erreichbaren Remote laesst sich der beglaubigte Stand schlicht
// nicht feststellen. Das ist kein Grund, dem lokalen Stand zu glauben.
console.error(
`Der beglaubigte Stand ist nicht feststellbar: ${REMOTE}/${zweig} nicht erreichbar.\n` +
`Ohne Remote kann nicht entschieden werden, ob der lokale Stand jemals notarisiert wurde.\n` +
`Das ist ein Verfügbarkeitsproblem kein Freibrief. Erst Verbindung klären, dann erneut prüfen.\n` +
(f.ok ? '' : ` git-Meldung: ${f.out}\n`),
);
process.exit(3);
}
} else if (process.env.NOTARY_ALLOW_LOCAL === 'true') {
// Ausdruecklich gewaehlter lokaler Betrieb (Gegenbuch auf eigenem Rechner,
// ohne zusaetzliche Ablage ausserhalb). Die Erfolgsmeldung benennt, was das
// abdeckt und was nicht ein glattes „OK“ ohne Einordnung gibt es nicht.
NUR_LOKAL = true;
} else {
console.error(
`Kein Remote '${REMOTE}' konfiguriert. Ein Gegenbuch, das nur lokal liegt, schützt nichts \n` +
`es steht im selben Zugriffsbereich wie alles andere auf diesem Rechner.\n` +
`Für einen bewussten lokalen Testlauf: NOTARY_ALLOW_LOCAL=true`,
);
process.exit(3);
}
// Lokale Commits, die dem Remote voraus sind, sind nicht beglaubigt.
if (ref !== 'HEAD') {
const voraus = gitStill('rev-list', '--count', `${ref}..HEAD`);
if (voraus.ok && Number(voraus.out) > 0) {
alarm(
`${voraus.out} lokale(r) Commit(s) wurden nie gepusht das Gegenbuch ist insoweit ` +
`nicht beglaubigt.\n` +
`ZUERST PRÜFEN, wie sie entstanden sind: ein abgebrochener Lauf hinterlässt so einen ` +
`Commit ebenso wie ein falsch signierter (dann darf er NICHT gepusht werden, sonst ist ` +
`die Kette dauerhaft rot).\n` +
` Ansehen mit: git log ${ref}..HEAD --format='%h %G? %GF %s'`,
);
}
}
// ---------------------------------------------------------------------------
// 1a) Anker-Refs: das Gedaechtnis liegt im Substrat, nicht auf diesem Rechner.
//
// Jeder verankerte Checkpoint bekommt zusaetzlich einen eigenen Ref
// `refs/notary/seq-N`. Der ueberlebt einen Force-Push auf `main`, weil er ein
// anderer Ref ist. Damit ist die hoechste je existierende Nummer aus dem
// Remote rekonstruierbar auch dann, wenn der lokale Beobachtungsspeicher
// fehlt.
//
// Das schliesst die Luecke, die ein reiner Host-Speicher offenlaesst: Ein
// Angreifer, der ihn loescht, brauchte sonst nur EIN „erste Beobachtung“
// zu ueberstehen danach haette der Waechter den zurueckgespulten Stand als
// Wahrheit adoptiert und dauerhaft gruen gemeldet. Ein gutartiger Default, den
// ein Angreifer gezielt erzeugen kann, ist kein sicherer Default.
// ---------------------------------------------------------------------------
const ankerRefs = (() => {
const r = gitStill('ls-remote', REMOTE, 'refs/notary/seq-*');
if (!r.ok || !r.out) return [];
return r.out
.split('\n')
.map((z) => z.trim().split(/\s+/))
.filter((t) => t.length === 2)
.map(([sha, name]) => ({ sha, seq: Number.parseInt(name.replace(/.*seq-/, ''), 10) }))
.filter((e) => Number.isFinite(e.seq))
.sort((a, b) => a.seq - b.seq);
})();
const hoechsterAnker = ankerRefs[ankerRefs.length - 1] || null;
const remoteKopf = git('rev-parse', ref);
let ankerBelegt = false;
if (hoechsterAnker) {
// Der beglaubigte Checkpoint muss im aktuellen Kopf noch enthalten sein.
// Ist er es nicht, wurde `main` an ihm vorbei zurueckgespult und das faellt
// hier auf, ganz ohne lokalen Speicher.
const drin =
hoechsterAnker.sha === remoteKopf ||
gitStill('merge-base', '--is-ancestor', hoechsterAnker.sha, remoteKopf).ok;
if (!drin) {
alarm(
`Der verankerte Checkpoint ${hoechsterAnker.seq} ist im aktuellen Stand nicht mehr ` +
`enthalten die Reihe wurde an ihm vorbei zurückgespult.\n` +
` verankert: ${hoechsterAnker.sha.slice(0, 12)} (refs/notary/seq-${hoechsterAnker.seq})\n` +
` jetzt : ${remoteKopf.slice(0, 12)}\n` +
`Diese Refs überleben einen Force-Push auf ${zweig} deshalb fällt es auf.`,
);
}
ankerBelegt = true;
}
const remoteKennung = gitStill('remote', 'get-url', REMOTE).out || zweig;
function beobachtungenLesen() {
if (!existsSync(STATE_FILE)) return [];
return readFileSync(STATE_FILE, 'utf8')
.split('\n')
.filter((z) => z.trim())
.map((z) => {
try { return JSON.parse(z); } catch { return null; }
})
.filter((e) => e && e.remote === remoteKennung && e.zweig === zweig);
}
const beobachtungen = beobachtungenLesen();
const letzteBeobachtung = beobachtungen[beobachtungen.length - 1];
let ffBelegt = false;
// ---------------------------------------------------------------------------
// 1c) Fehlt das Waechter-Gedaechtnis und ist das harmlos?
//
// „Kein Speicher“ ist zweierlei, und der Unterschied entscheidet alles:
// * Es gab hier noch nie etwas → echte Erstinbetriebnahme, legitim.
// * Es gibt bereits eine notarisierte Historie, aber kein Gedaechtnis mehr
// → das Gedaechtnis ging NACH der Etablierung verloren. Das ist die
// Anomalie, nicht der Normalfall und genau der Zustand, den ein
// Angreifer nach einem Rewind gezielt herstellt, um die naechste Baseline
// auf seinen Stand zu setzen.
//
// Deshalb: kein stilles Neu-Baselinen. Eine Baseline zu setzen heisst „ich
// verbuerge mich, DAS ist der wahre Kopf“ dieser Akt gehoert bestaetigt.
// ---------------------------------------------------------------------------
// Nur der SCHREIBLAUF setzt eine Baseline nur dort ist die Sperre noetig.
// Ein Auditor mit frischem Klon hatte nie ein Gedaechtnis; ihm Code 4
// vorzuwerfen waere Laerm. Er bekommt stattdessen exit 3 mit Vorbehalt.
//
// Das Kurzschliessen ueber `ankerBelegt` ist entfallen (R180-01): ein stehen
// gelassener niedriger Anker durfte die Sperre nicht aufheben.
if (!letzteBeobachtung && !NUR_PRUEFEN) {
const remoteHatHistorie = gitStill('show', `${ref}:${LEDGER_NAME}`).ok;
if (remoteHatHistorie && process.env.NOTARY_ADOPT_ACK !== 'true') {
console.error(
'Das Wächter-Gedächtnis fehlt, obwohl bereits eine notarisierte Historie existiert.\n' +
'Das ist KEIN Normalzustand: Entweder wurde dieser Rechner neu aufgesetzt, oder der\n' +
'Beobachtungsspeicher wurde entfernt letzteres ist genau das, was nach einem\n' +
'Zurückspulen passieren müsste, damit der nächste Lauf den gewaschenen Stand als\n' +
'Wahrheit übernimmt.\n\n' +
'Ohne Bestätigung wird hier nichts als Ausgangspunkt akzeptiert. Bitte erst klären:\n' +
` * Stimmt der aktuelle Kopf ${remoteKopf.slice(0, 12)} mit dem überein, was zuletzt\n` +
' beglaubigt wurde? (Protokoll, Monitoring, zweite Kopie des Gegenbuchs)\n' +
' * Gibt es refs/notary/seq-* auf dem Server? Dann lässt sich der Stand daraus\n' +
' rekonstruieren, statt ihn zu glauben.\n\n' +
'Erst wenn das geklärt ist: NOTARY_ADOPT_ACK=true setzen.',
);
process.exit(4);
}
if (!remoteHatHistorie && process.env.NOTARY_GENESIS_ACK !== 'true') {
console.error(
'Erstinbetriebnahme: Es gibt weder ein Wächter-Gedächtnis noch eine notarisierte\n' +
'Historie. Das ist plausibel aber die erste Baseline ist ein Vertrauensakt und\n' +
'wird deshalb einmalig bestätigt:\n' +
' NOTARY_GENESIS_ACK=true',
);
process.exit(4);
}
}
if (letzteBeobachtung) {
if (letzteBeobachtung.sha === remoteKopf) {
ffBelegt = true;
} else {
// Ist der zuletzt gesehene Kopf ein Vorfahre des jetzigen? Dann ist die
// Kette gewachsen. Sonst wurde sie zurueckgespult oder ersetzt.
const vorfahre = gitStill('merge-base', '--is-ancestor', letzteBeobachtung.sha, remoteKopf);
if (vorfahre.ok) {
ffBelegt = true;
} else {
alarm(
`Der Remote-Kopf ist KEIN Nachfahre des zuletzt beobachteten Standes ` +
`die Reihe wurde zurückgespult oder ersetzt.\n` +
` zuletzt gesehen: ${letzteBeobachtung.sha.slice(0, 12)} (${letzteBeobachtung.ts})\n` +
` jetzt : ${remoteKopf.slice(0, 12)}\n` +
`Genau das soll der serverseitige Rewind-Schutz verhindern. Prüfen, wer ` +
`force-pushen konnte und ob die Datenbank dazu passend gekürzt wurde.`,
);
}
}
}
const beobachtungMerken = () => {
try {
mkdirSync(dirname(STATE_FILE), { recursive: true });
appendFileSync(
STATE_FILE,
JSON.stringify({
ts: new Date().toISOString(),
remote: remoteKennung,
zweig,
sha: git('rev-parse', ref),
}) + '\n',
);
} catch (e) {
console.warn(
`HINWEIS: Beobachtung konnte nicht festgehalten werden (${STATE_FILE}).\n` +
` Ohne sie erkennt der nächste Lauf ein Zurückspulen nicht. Grund: ` +
`${e instanceof Error ? e.message : String(e)}`,
);
}
};
// Fehlende Anker nachziehen (Pentest R180-02).
//
// Scheitert der Anker-Push, stand `main` schon auf dem neuen Checkpoint,
// waehrend der Anker fehlte und niemand holte das nach. Der Normalbetrieb
// senkte damit den Substrat-Hoechststand still um eins und riss genau das
// Fenster auf, das R180-01 ausnutzt. Deshalb wird vor allem anderen
// nachgezogen, was fehlt.
let fehlendeAnker = [];
function ankerNachziehen(bisherLaenge) {
const vorhanden = new Set(ankerRefs.map((a) => a.seq));
fehlendeAnker = [];
for (let n = 1; n <= bisherLaenge; n++) if (!vorhanden.has(n)) fehlendeAnker.push(n);
if (!fehlendeAnker.length) return;
// Im PRUEFMODUS wird NICHT nachgezogen (Pentest R181-01/-02).
//
// Zwei Gruende, und beide sind grundsaetzlich:
// 1. Der Pruefmodus sagt zu, nichts zu veraendern und kein Schreibrecht zu
// brauchen. Ein Audit, das dabei still das Substrat mutiert, bricht
// genau diese Zusage.
// 2. `refs/notary/*` MUSS serverseitig gegen Schreibzugriff gesperrt sein
// (R180-01) dieselbe Sperre lehnt den Nachtrag ab. Ein read-only
// pruefender Auditor bekaeme dadurch dauerhaft einen Fehler auf einer
// voellig gueltigen Kette, den er gar nicht beheben kann.
//
// Der fehlende Anker wird gemeldet, nicht repariert. Reparieren ist Sache
// des Notar-Schreiblaufs, dessen Identitaet in der Serversperre eine
// Ausnahme fuer das ANLEGEN von `refs/notary/*` braucht (nicht fuers
// Loeschen oder Ueberschreiben sonst hebelt eine kompromittierte
// Notar-Identitaet den Schutz aus R180-01 selbst aus).
if (NUR_PRUEFEN || NUR_LOKAL || !PUSH) return;
// Die n-te Aenderung am Gegenbuch entspricht Checkpoint n.
const commits = gitStill('log', '--reverse', '--format=%H', ref, '--', LEDGER_NAME);
const liste = commits.ok ? commits.out.split('\n').filter(Boolean) : [];
let nachgezogen = 0;
const nachgetragen = new Set();
for (const n of fehlendeAnker) {
const sha = liste[n - 1];
if (!sha) continue;
if (gitStill('push', REMOTE, `${sha}:refs/notary/seq-${n}`).ok) {
nachgezogen++;
nachgetragen.add(n);
}
}
if (nachgezogen) console.log(`Fehlende Anker nachgetragen: ${nachgezogen}`);
fehlendeAnker = fehlendeAnker.filter((n) => !nachgetragen.has(n));
if (fehlendeAnker.length) {
console.error(
`${fehlendeAnker.length} Anker-Ref(s) fehlen und ließen sich nicht nachtragen: ` +
`${fehlendeAnker.join(', ')}\n` +
'Ohne sie ist der beglaubigte Höchststand nicht aus dem Server rekonstruierbar \n' +
'genau die Eigenschaft, auf der dieser Schutz beruht.\n' +
'Wahrscheinlichste Ursache: Die Serversperre für refs/notary/* lässt auch die\n' +
'Notar-Identität nicht anlegen. Sie braucht dort eine Ausnahme zum ANLEGEN \n' +
'ausdrücklich nicht zum Löschen oder Überschreiben.',
);
process.exit(5);
}
}
// ---------------------------------------------------------------------------
// 2) Signaturen der GESAMTEN Historie pruefen das war der fehlende Leser.
// ---------------------------------------------------------------------------
const commits = gitStill('log', '--format=%H %G?', ref);
if (!commits.ok) alarm(`Historie nicht lesbar: ${commits.out}`);
const zeilen = commits.out.split('\n').filter((z) => z.trim());
if (SIGN) {
// NUR `G` (Pentest R176-01). Bei SSH-Signaturen bedeutet `U` woertlich
// „gute Signatur, aber kein passender Principal“ also: der Schluessel
// steht NICHT in `allowed_signers`. Wer `U` akzeptiert, akzeptiert jeden
// selbst erzeugten Schluessel und macht damit genau den Vertrauensanker
// wirkungslos, den er zu pruefen glaubt. Live gezeigt: mit einem fremden
// Schluessel re-signiertes, force-gepushtes Gegenbuch lief als „gültig“
// durch.
//
// Hinweis fuer GPG statt SSH: dort liefert ein gueltiger, aber nicht
// ownertrusted Schluessel ebenfalls `U`. Dann Ownertrust auf den
// Notar-Schluessel setzen NICHT `U` wieder zulassen.
const schlecht = zeilen
.map((z) => z.split(' '))
.filter(([, status]) => status !== 'G');
// Der allererste (leere) init-Commit darf unsigniert sein, sonst nichts.
const relevant = schlecht.filter(([sha]) => {
const dateien = gitStill('show', '--name-only', '--format=', sha);
return dateien.ok && dateien.out.includes(LEDGER_NAME);
});
// Optional schaerfer: den erwarteten Signierschluessel festnageln, statt sich
// allein auf die Bewertung von git zu verlassen.
if (PIN) {
const fremde = zeilen
.map(([, ] , i) => i)
.map((i) => zeilen[i].split(' ')[0])
.filter((sha) => {
const dateien = gitStill('show', '--name-only', '--format=', sha);
if (!dateien.ok || !dateien.out.includes(LEDGER_NAME)) return false;
const fp = gitStill('log', '-1', '--format=%GF', sha);
return !fp.ok || fp.out.trim() !== PIN;
});
if (fremde.length) {
alarm(
`${fremde.length} Commit(s) wurden mit einem anderen als dem festgelegten ` +
`Schlüssel signiert (erwartet: ${PIN}):\n ` +
fremde.map((sha) => sha.slice(0, 12)).join('\n '),
);
}
}
if (relevant.length) {
alarm(
`${relevant.length} Commit(s) mit Gegenbuch-Änderungen tragen KEINE gültige Signatur:\n ` +
relevant.map(([sha, st]) => `${sha.slice(0, 12)} (%G?=${st})`).join('\n ') +
`\nDie Reihe ist damit nicht vertrauenswürdig.`,
);
}
}
// ---------------------------------------------------------------------------
// 3) Wahrheit aus dem signierten Baum lesen NICHT aus der Arbeitsdatei.
// ---------------------------------------------------------------------------
const ausBaum = gitStill('show', `${ref}:${LEDGER_NAME}`);
const beglaubigterInhalt = ausBaum.ok ? ausBaum.out : '';
const arbeitsInhalt = existsSync(LEDGER) ? readFileSync(LEDGER, 'utf8').trim() : '';
if (arbeitsInhalt !== beglaubigterInhalt.trim()) {
alarm(
'Die lokale Gegenbuch-Datei weicht vom signierten Stand ab. Entweder wurde sie ' +
'von Hand verändert, oder ein früherer Lauf ist zwischen Schreiben und Commit ' +
'abgebrochen. Nichts wird angehängt.\n' +
` Wiederherstellen mit: git checkout ${ref} -- ${LEDGER_NAME}`,
);
}
const bisher = beglaubigterInhalt
.split('\n')
.filter((z) => z.trim())
.map((z) => JSON.parse(z));
for (let i = 0; i < bisher.length; i++) {
if (bisher[i].seq !== i + 1) {
alarm(`Lücke in der Checkpoint-Reihe an Position ${i + 1} (seq=${bisher[i].seq}).`);
}
}
ankerNachziehen(bisher.length);
if (hoechsterAnker && bisher.length < hoechsterAnker.seq) {
alarm(
`Die Checkpoint-Reihe ist kürzer als der Server beglaubigt hat: ` +
`verankert bis ${hoechsterAnker.seq}, vorhanden ${bisher.length}.\n` +
`Die vorhandenen Einträge sind korrekt signiert es fehlen welche am Ende.`,
);
}
if (Number.isFinite(MIN_SEQ) && bisher.length < MIN_SEQ) {
alarm(
`Die Checkpoint-Reihe ist kürzer als bekannt: erwartet mindestens ${MIN_SEQ}, ` +
`vorhanden ${bisher.length}.\n` +
`Die vorhandenen Einträge sind zwar korrekt signiert aber es fehlen welche am Ende. ` +
`Das ist das Bild eines Rewinds per Force-Push.`,
);
}
// ---------------------------------------------------------------------------
// 4) Abgleich mit dem CRM, VOR dem Anhaengen.
// ---------------------------------------------------------------------------
const letzter = bisher[bisher.length - 1];
if (!CRM_TOKEN) await anmelden();
const aktuell = await hole('/api/audit-logs/checkpoint');
// Die Vollpruefung wird IMMER geholt, auch beim allerersten Lauf: Aus ihr
// stammt die Zahl der protokollierten Neuberechnungen, und die gehoert schon
// in den ersten Eintrag - sonst gaebe es beim zweiten Lauf keine Grundlage,
// gegen die sich ein neuer Rehash abheben koennte.
const pruefung = await hole('/api/audit-logs/verify', 'POST');
const rehashListe = Array.isArray(pruefung?.rehashes) ? pruefung.rehashes : null;
const rehashAnzahl = rehashListe ? rehashListe.length : null;
const rehashLetzte = rehashListe && rehashListe.length
? rehashListe[rehashListe.length - 1].id
: null;
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.`,
);
}
// `valid: false` wird HART gewertet, unabhaengig von der erklaerenden Prosa
// (Pentest R183-02). Ein Cleanup mit abgesenkter Aufbewahrung kann tausende
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
// ausweisen die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
// das Feld, nicht der Satz.
if (pruefung && pruefung.valid === false) {
alarm(
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
` CRM-Text: ${pruefung.message}\n` +
'Auch wenn dieser Text harmlos klingt: Es wird nichts beglaubigt, solange die\n' +
'Prüfung nicht sauber ist. Häufigste Ursache ist ein Cleanup mit verkürzter\n' +
'Aufbewahrung dann wurden Einträge endgültig gelöscht.',
);
}
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
// Ein fehlender beglaubigter Eintrag ist IMMER ein Befund - auch wenn eine
// Neuberechnung bestaetigt wurde. Bestaetigt wird der Rehash, nicht das
// Verschwinden von Zeilen.
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
// ---------------------------------------------------------------
// Neuberechnung der Kette (Rehash) als EIGENE Alarmbedingung.
//
// Ein Rehash wurde bisher nur als Nebenwirkung gefangen: Er aendert jeden
// Hash, also stimmt der beglaubigte Kettenkopf nicht mehr und der Vergleich
// weiter oben schlaegt an. Das funktioniert - aber es ist ein Zufallstreffer
// und keine gebaute Warnung. Verschoebe sich der Anker irgendwann (anderer
// beglaubigter Wert, anderer Vergleich), waere der Melder lautlos weg.
//
// Deshalb haengt der Alarm jetzt an der Waffe selbst, nicht an ihrer Spur -
// dieselbe Lehre wie R184-01 (Gate am Ausloeser statt an der Wirkung) und
// R185-01 (Wurzelwechsel statt `valid`).
//
// Das Restrisiko, das dieser Melder abdeckt: `cleanup` + `rehash` OHNE
// erneutes Siegeln, ueber Inhalt, der gar nicht versiegelt ist. Dort greift
// weder das Bestandssiegel noch der Wurzelwechsel.
if (rehashAnzahl !== null && typeof letzter.rehashCount === 'number') {
if (rehashAnzahl > letzter.rehashCount) {
const neue = rehashAnzahl - letzter.rehashCount;
if (REHASH_ACK && String(rehashLetzte) === REHASH_ACK) {
rehashBestaetigt = true;
console.log(
`Neuberechnung bestätigt (NOTARY_REHASH_ACK=${REHASH_ACK}). ` +
'NOTARY_REHASH_ACK danach wieder leeren.',
);
} else {
const l = rehashListe[rehashListe.length - 1];
const vb = l.vorbefund;
alarm(
`Die Hash-Kette wurde neu berechnet (${neue} neue${neue === 1 ? 'r' : ''} Vorgang` +
`${neue === 1 ? '' : 'e'} seit der letzten Beglaubigung).\n` +
` zuletzt: ${l.zeitpunkt} (Eintrag ${l.id}, ${l.neuBerechnet} Zeilen)\n` +
(vb
? ` Befund unmittelbar davor: ${vb.manipuliert} beanstandet, ${vb.luecken} Lücken\n`
: ' Der Zustand davor ist nicht mehr feststellbar.\n') +
(l.signiert ? '' : ' ACHTUNG: Dieser Rehash-Eintrag trägt keine gültige Signatur.\n') +
'Ein Rehash verknüpft alle Einträge neu. Lücken, die vorher eine Löschung\n' +
'sichtbar gemacht hätten, verschwinden dabei aus der Kette die Prüfung im CRM\n' +
'meldet danach wieder „lückenlos". Genau deshalb ist das hier ein Alarm und\n' +
'kein Hinweis.\n' +
'War es geplant, bestätigen und danach wieder leeren:\n' +
` NOTARY_REHASH_ACK=${rehashLetzte}`,
);
}
}
} else if (rehashAnzahl !== null && typeof letzter.rehashCount !== 'number') {
// Gegenbuch aus der Zeit vor diesem Melder: Es gibt keine Grundlage, gegen
// die sich etwas abheben koennte. Stillschweigend zu alarmieren waere ein
// Fehlalarm bei jedem Bestandsbuch; stillschweigend zu uebernehmen waere
// eine unsichtbare Annahme. Also: uebernehmen und es sagen.
console.log(
`Grundlage für die Rehash-Überwachung wird gesetzt: ${rehashAnzahl} protokollierte ` +
'Neuberechnung(en) im CRM. Ab dem nächsten Lauf fällt jede weitere auf.',
);
}
// Der Kopf-Hash-Vergleich steht bewusst NACH der Rehash-Pruefung.
//
// Zwei Gruende: Erstens benennt der Rehash-Alarm die Ursache, waehrend
// „Eintrag wurde veraendert“ nur die Wirkung beschreibt - bei gleicher Lage
// ist die praezisere Meldung die nuetzlichere. Zweitens aendert eine
// Neuberechnung ZWANGSLAEUFIG jeden Hash; stuende dieser Vergleich davor
// oder ungeschuetzt dahinter, liesse sich eine bestaetigte Neuberechnung nie
// aufloesen und die Bestaetigung waere wertlos.
if (rueck.atHash !== letzter.chainHead && !rehashBestaetigt) {
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.');
}
// Wechsel der Siegelwurzel ist ein ALARM, kein Hinweis (Pentest R185-01).
//
// Vorher stand hier ein console.warn und der Rueckgabecode blieb 0 also
// genau das Muster, das wir dem CRM zweimal angekreidet haben: die Warnung
// steht in der Prosa, die Maschine meldet „in Ordnung“. Das ist hier
// besonders teuer, weil `valid` ein ERSETZENDES Siegel per Konstruktion
// ueberlebt: Wer eine Altzeile per Datenbankzugriff entfernt und danach neu
// siegelt, bekommt eine passende Wurzel und eine beglaubigte Luecke. Die
// Vollpruefung sagt dann `true`. Der Wurzelwechsel ist der EINZIGE
// maschinell erkennbare Anker dagegen und der gehoert in den Alarm.
//
// Ein legitimes Neu-Siegeln loest hier einmal aus. Das ist gewollt: ein
// Austausch der Beweisgrundlage soll einmal wehtun und bestaetigt werden,
// statt lautlos durchzulaufen.
if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) {
if (siegelBestaetigt(aktuell.sealRoot)) {
console.log(
`Siegelwechsel bestätigt (NOTARY_SEAL_ACK): ${letzter.sealRoot.slice(0, 16)}… → ` +
`${aktuell.sealRoot.slice(0, 16)}…. Die neue Wurzel wird beglaubigt.\n` +
' NOTARY_SEAL_ACK danach wieder leeren.',
);
} else alarm(
'Das Bestandssiegel wurde ERSETZT die beglaubigte Grundlage ist eine andere.\n' +
` beglaubigt: ${letzter.sealRoot.slice(0, 16)}… (${letzter.sealLeafCount} Blätter)\n` +
` jetzt : ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
'Ein erneutes Siegeln schreibt den AKTUELLEN Stand des Altbestands fest. Wurden\n' +
'vorher Einträge entfernt, sind deren Lücken danach beglaubigt und die Prüfung im\n' +
'CRM meldet wieder valid:true dieser Wurzelwechsel ist die einzige Spur davon.\n' +
'Wenn das keine geplante Maßnahme war: Im CRM das Ereignis AUDIT_SEAL_CHANGED und\n' +
'die CRITICAL-Zeile zu /api/audit-logs/seal-backlog ansehen (wer, wann, Vorbefund).\n' +
'War es geplant, den Wechsel bestätigen und danach wieder leeren:\n' +
` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
);
}
// Erstsiegelung: vorher nichts beglaubigt, jetzt eine Wurzel. Das ist der
// eine legitime Einrichtungsschritt aber auch das Fenster, in dem ein
// beschnittener Altbestand einmalig festgeschrieben werden koennte. Deshalb
// ebenfalls melden, mit eigenem Text statt stillschweigend zu uebernehmen.
if (!letzter.sealRoot && aktuell.sealRoot) {
if (siegelBestaetigt(aktuell.sealRoot)) {
console.log(
`Erstsiegelung bestätigt (NOTARY_SEAL_ACK): ${aktuell.sealRoot.slice(0, 16)}…. ` +
'Die Wurzel wird beglaubigt.\n NOTARY_SEAL_ACK danach wieder leeren.',
);
} else alarm(
'Erstmals ein Bestandssiegel gesetzt bisher war keines beglaubigt.\n' +
` Wurzel: ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
'War das dein Einrichtungsschritt, ist alles in Ordnung: Der nächste Lauf läuft\n' +
'wieder auf 0, sobald diese Wurzel im Gegenbuch steht.\n' +
'War es das NICHT, dann hat jemand den Stand des Altbestands festgeschrieben \n' +
'samt aller Lücken, die zu diesem Zeitpunkt bestanden.\n' +
'Zum Bestätigen setzen und danach wieder leeren:\n' +
` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
);
}
}
// ---------------------------------------------------------------------------
// 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,
// Grundlage fuer die Rehash-Ueberwachung des naechsten Laufs.
rehashCount: rehashAnzahl,
rehashLast: rehashLetzte,
};
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);