Files
opencrm/tools/audit-notary/notary.mjs
T
duffyduckandClaude Opus 5 8d2dfb8be1 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (R177)
R177-01 (MEDIUM): Der Pin war optional. Ohne ihn war der Vertrauensanker die
gesamte allowed_signers-Menge, nicht der eine Notar-Schluessel: ein zweiter
dort gelisteter Schluessel konnte das Gegenbuch waschen und force-pushen, und
%G? war G. R176-01 hatte "jeder selbst erzeugte Schluessel" geschlossen, "jeder
erlaubte Schluessel" blieb offen. Fix: Pin wird aus user.signingkey abgeleitet;
laesst er sich nicht bestimmen, wird abgebrochen statt die ganze Liste zu
akzeptieren.

R177-02 (MEDIUM): Der Schreib-Lauf prueft den frischen Commit nur auf %G?,
nicht auf den Pin. Ein Notar-Host mit falsch konfiguriertem Schluessel meldete
"beglaubigt" und pushte - und ab da war die Kette dauerhaft rot, behebbar nur
per Force-Push, den die Branch-Protection gerade verhindern soll. Fix:
Pin-Abgleich am frischen Commit vor dem Push, bei Abweichung Ruecknahme.

Kleinkram: NOTARY_ALLOW_LOCAL faerbt Erfolgsmeldungen ein und pusht nicht mehr
ins Leere; CRM-Fehler liefern eine erklaerende Zeile statt Node-Stacktrace.

Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff -> Alarm
exit 2 auch ohne gesetzten Pin; Schreiblauf mit falschem Schluessel ->
zurueckgerollt, nichts gepusht; CRM nicht erreichbar/401 -> saubere Meldung;
Lokalmodus eingefaerbt; saubere Historie ohne Fehlalarm.

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

411 lines
17 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* OpenCRM Gegenbuch (externe Notarisierung)
* ===========================================
*
* Laeuft NICHT auf dem CRM-Server, sondern auf einem zweiten Rechner. Der
* Signaturschluessel und das Push-Recht leben dort nicht in den
* Deploy-Secrets des CRM.
*
* ZENTRALE LEHRE (Pentest R175-01): Append-only und Signieren allein reichen
* nicht es braucht einen VERIFIZIERENDEN LESER. Die erste Fassung las ihre
* Wahrheit aus der lokalen Arbeitsdatei und hat nie eine Signatur geprueft;
* das `-S` war write-only ohne Konsument. Wer den Notar-Host beschreiben
* konnte, hat dem Skript beliebige „beglaubigte“ Zeilen untergeschoben und den
* abgeschnittenen Zustand als neue Wahrheit zementiert ohne Alarm.
*
* Deshalb gilt jetzt:
* - Wahrheitsquelle ist der SIGNIERTE Commit-Baum (bevorzugt der Remote-Kopf),
* nicht die Arbeitsdatei.
* - JEDER Commit der Historie muss eine gute Signatur tragen.
* - Weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen.
* - Geschrieben wird erst nach erfolgreichem, signiertem Commit; schlaegt er
* fehl, wird die Datei zurueckgerollt (Pentest R175-03).
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, basename } from 'node:path';
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.
let PIN = process.env.NOTARY_SIGNER_FINGERPRINT || null;
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.' : '';
const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); };
// ---------------------------------------------------------------------------
// 1) Vertrauenswuerdigen Stand bestimmen: bevorzugt der Remote-Kopf.
// ---------------------------------------------------------------------------
const zweig = git('rev-parse', '--abbrev-ref', 'HEAD');
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 damit ` +
`nur lokal entweder pushen oder untersuchen, wie sie entstanden sind.`,
);
}
}
// ---------------------------------------------------------------------------
// 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}).`);
}
}
// ---------------------------------------------------------------------------
// 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) {
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(),
);
process.exit(0);
}
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.');
if (PUSH && !NUR_LOKAL) {
const p = gitStill('push', REMOTE, zweig);
if (!p.ok) {
console.error(
`Push fehlgeschlagen der Checkpoint liegt nur lokal und gilt nicht als beglaubigt.\n${p.out}`,
);
process.exit(1);
}
}
console.log(`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).` + OHNE_ANKER());