diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index d533c838..e567afee 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -700,7 +700,7 @@ export async function getCheckpoint(atId?: number): Promise<{ maxId: number | null; chainHead: string | null; sealRoot: string | null; - sealStatus: string; + sealLeafCount: number; atId?: number; atHash?: string | null; }> { @@ -708,22 +708,60 @@ export async function getCheckpoint(atId?: number): Promise<{ orderBy: { id: 'desc' }, select: { id: true, hash: true }, }); - const pruefung = await verifyIntegrity(); + + // BEWUSST KEIN verifyIntegrity() hier (Pentest R175-02): das laeuft ueber + // alle Zeilen und kostet bei grossen Logs Sekunden je Aufruf – ein + // authentifizierter Leser koennte damit die Datenbank in die Knie zwingen. + // Gebraucht wird von der Vollpruefung ohnehin nur die Siegel-Wurzel, und die + // steht im juengsten gueltigen Marker. Der Kopf-Hash kommt direkt aus der + // Kopfzeile. + const c3 = await prisma.auditLog.aggregate({ + where: { hashVersion: { gte: 3 } }, + _min: { id: true }, + }); + const v3FromId = c3._min.id; + const schluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter( + (k): k is string => !!k, + ); + const markerKandidaten = await prisma.auditLog.findMany({ + where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT }, + orderBy: { id: 'desc' }, + take: 20, + }); + let sealRoot: string | null = null; + if (schluessel.length && v3FromId !== null) { + for (const r of markerKandidaten) { + if (r.id < v3FromId || r.hashVersion < 3) continue; + if (!schluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k))) continue; + try { + const m = JSON.parse(r.changesAfter || '{}'); + if (typeof m.root === 'string' && m.root.length > 0) { + sealRoot = m.root; + break; + } + } catch { + /* naechster Kandidat */ + } + } + } + const blattAnzahl = await prisma.auditBacklogSeal.count(); const ergebnis: { ts: string; maxId: number | null; chainHead: string | null; sealRoot: string | null; - sealStatus: string; + sealLeafCount: number; atId?: number; atHash?: string | null; } = { ts: new Date().toISOString(), maxId: kopf?.id ?? null, chainHead: kopf?.hash ?? null, - sealRoot: pruefung.backlogSealRoot, - sealStatus: pruefung.backlogSealStatus, + sealRoot, + // Reicht der Gegenstelle, um das Verschwinden des Siegels zu bemerken: + // Blaetter ohne Wurzel = Marker entfernt. + sealLeafCount: blattAnzahl, }; if (atId !== undefined && Number.isFinite(atId)) { diff --git a/docs/todo.md b/docs/todo.md index 36019207..89fa7d83 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,44 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔏 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)** (2026-08-18) + - **R175-01 (HIGH)** – Der Kernsatz des Pentesters: „Append-only allein + reicht nicht, es braucht einen verifizierenden Leser.“ Meine erste Fassung + signierte zwar, **prüfte aber nie**: Sie las ihre Wahrheit per + `readFileSync` aus der lokalen **Arbeitsdatei**, nirgends im Repo gab es + ein `git verify-commit`. Das `-S` war write-only ohne Konsument. Live + reproduziert: DB-Tail abgeschnitten UND die lokale Ledger-Zeile angepasst → + „OK, Checkpoint beglaubigt“, exit 0, kein Alarm. Der gekuerzte Zustand + wurde zur neuen Wahrheit. + Fix: Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der + Remote-Kopf); jeder Commit mit Gegenbuch-Aenderung muss eine gueltige + Signatur tragen; weicht die Arbeitsdatei vom signierten Stand ab, wird + abgebrochen; lokale, nie gepushte Commits gelten nicht als beglaubigt; + die Signatur des frisch erzeugten Commits wird gegengeprueft. + Dazu ein **Pruefmodus** `--check` fuer Auditoren (alle Kontrollen, kein + Schreibrecht noetig). + - **R175-02 (MEDIUM)** – `/checkpoint` fuhr je Aufruf ein volles + `verifyIntegrity()` (O(n), gemessen 0,85 s bei 16k Zeilen) – authentifizierte + DoS-Verstaerkung. Gebraucht wurde davon nur die Siegel-Wurzel. Jetzt: + Kopf-Hash direkt aus der Kopfzeile, Wurzel aus dem juengsten gueltigen + Marker (eine Abfrage + Signaturpruefung), plus `sealLeafCount` statt des + teuren Status. + - **R175-03 (LOW→MED)** – `writeFileSync` lief vor dem Commit: schlug der + signierte Commit fehl, blieb eine verwaiste Zeile liegen, die der + Folgelauf absorbierte und zementierte. Jetzt Ruecknahme bei Fehlschlag + (`git checkout`), und `NOTARY_SIGN=false` verlangt zusaetzlich + `NOTARY_INSECURE_ACK=…` – der Reflex „Cron hakt, Signatur abschalten“ + soll nicht ausreichen. + - Verifiziert mit **echtem SSH-Signaturschluessel** gegen eine CRM-Attrappe: + stilles Waschen (DB gekuerzt + lokale Datei gebogen) → Alarm statt „OK“; + frei erfundene, nie committete Zeile → Alarm; unsignierter Commit in der + Historie → Alarm; Commit-Fehlschlag → Datei zurueckgerollt, keine + Geisterzeile; `NOTARY_SIGN=false` ohne Bestaetigung → verweigert; + Pruefmodus meldet sauber ohne anzuhaengen. Alle Testartefakte geloescht. + - Seine Bestaetigungen: Key-Trennung im Deploy haelt (`printenv` im + Container zeigt keinen Notar-Schluessel); `audit:read` kann am + Checkpoint keine falschen Werte injizieren. + - [x] **⚖️ Gegenbuch: externe Notarisierung der Audit-Kette** (2026-08-18) - Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in derselben Datenbank, die sie absichern sollen – der Pentest hat das ueber diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index 3730e316..31b9e956 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -43,6 +43,32 @@ Als Cronjob, stündlich: nur geliehen – wer Schreibrecht auf das Repository erlangt, schreibt die Historie sonst einfach um. Bei GitHub/GitLab: Branch-Protection auf `main`. +## Der entscheidende Punkt: es wird tatsächlich geprüft + +Die erste Fassung dieses Skripts hat signiert – aber **nie eine Signatur +geprüft**. Es las seine Wahrheit aus der lokalen Arbeitsdatei. Wer den +Notar-Rechner beschreiben konnte, hat ihm damit beliebige „beglaubigte" +Zeilen untergeschoben und einen abgeschnittenen Datenbestand als neue Wahrheit +festgeschrieben, ohne Alarm. Signieren ohne prüfenden Leser ist wertlos. + +Deshalb gilt jetzt: + +- Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der Remote-Kopf), + nicht die Arbeitsdatei +- **Jeder** Commit, der das Gegenbuch ändert, muss eine gültige Signatur tragen +- Weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen +- Geschrieben wird erst nach erfolgreichem, signiertem Commit – scheitert er, + wird die Datei zurückgerollt + +## Prüfmodus für Auditoren + +```bash +node notary.mjs --check +``` + +Führt alle Kontrollen aus, hängt aber nichts an und braucht kein Schreibrecht. +Geeignet für jemanden, der die Kette unabhängig nachvollziehen will. + ## Was das Skript erkennt | Angriff | Erkennung | @@ -51,6 +77,10 @@ Historie sonst einfach um. Bei GitHub/GitLab: Branch-Protection auf `main`. | Einträge am Ende abgeschnitten | aktuelle höchste ID kleiner als die beglaubigte | | Bestandssiegel verschwunden | vorher beglaubigt, jetzt nicht mehr vorhanden | | Gegenbuch selbst gekürzt | Lücke in der fortlaufenden Nummer | +| Gegenbuch lokal manipuliert | Arbeitsdatei weicht vom signierten Stand ab | +| Untergeschobener Commit | Commit ohne gültige Signatur in der Historie | +| Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf | +| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen | Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit @@ -67,5 +97,11 @@ im Log unter. CRM – dort ist ja nichts konfiguriert. Der Ausfall muss auf dem Gegenbuch-Rechner überwacht werden (z. B. Alarm, wenn der jüngste Commit älter als zwei Intervalle ist). -- **`NOTARY_SIGN=false`** existiert nur für Tests ohne hinterlegten Schlüssel. - Im Betrieb niemals setzen. +- **`NOTARY_SIGN=false`** existiert nur für Tests ohne hinterlegten Schlüssel + und verlangt zusätzlich `NOTARY_INSECURE_ACK=…`. Der Reflex „Cron hakt, also + Signatur abschalten" soll bewusst nicht ausreichen – er würde eine + ungeschützte Kette festschreiben. +- **Der Remote-Server muss Force-Push verhindern.** Das Skript prüft die + Signaturen, aber wenn jemand die Historie am Server überschreiben kann, + ersetzt er auch die signierten Commits durch eigene. Branch-Protection ist + Teil des Schutzes, nicht Zubehör. diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index dc1457df..b7058c30 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -3,109 +3,172 @@ * OpenCRM – Gegenbuch (externe Notarisierung) * =========================================== * - * WICHTIG: Dieses Skript laeuft NICHT auf dem CRM-Server. Es gehoert auf einen - * zweiten Rechner. Der Signaturschluessel und das Push-Recht auf das private - * Repository leben dort – nicht in den Deploy-Secrets des CRM. + * 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. * - * Der Grund: Alle Anker im CRM selbst (Hash-Kette, Versionsgrenze, Manifest, - * Bestandssiegel) sitzen in derselben Datenbank, die sie schuetzen sollen. Wer - * dort schreiben kann, kommt am Ende an jeden davon heran. Ein Gegenbuch an - * einem fremden Ort durchbricht das – aber NUR, solange der Schluessel dieses - * Ortes nicht im selben Zugriffsbereich liegt. Liegt er es doch, sieht es nach - * doppeltem Boden aus und ist keiner. + * 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. * - * Ablauf je Lauf: - * 1. Kontrollwert beim CRM abholen (nur lesend) - * 2. GEGEN die eigene Historie pruefen, BEVOR etwas angehaengt wird - * 3. Nur bei Unauffaelligkeit anhaengen, signiert committen, pushen - * 4. Bei Widerspruch: Exit-Code 2 und nichts schreiben - * - * Aufruf (Beispiel-Cron, stuendlich): - * 0 * * * * cd /pfad/zum/gegenbuch && CRM_URL=https://crm.example.de \ - * CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1 + * 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 } from 'node:path'; +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'; -// NUR fuer Tests ohne hinterlegten Schluessel. Im Betrieb NIEMALS setzen: ohne -// Signatur kann jeder, der Schreibrecht auf das Repository erlangt, die -// Checkpoint-Reihe faelschen - dann ist das Gegenbuch wertlos. -const SIGN = process.env.NOTARY_SIGN !== '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'; +// 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'; +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); } const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); +const gitStill = (...args) => { + try { return { ok: true, out: git(...args) }; } + catch (e) { return { ok: false, out: String(e.stderr || e.message) }; } +}; async function hole(pfad) { - const r = await fetch(`${CRM_URL}${pfad}`, { - headers: { Authorization: `Bearer ${CRM_TOKEN}` }, - }); + const r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } }); if (!r.ok) throw new Error(`${pfad} -> HTTP ${r.status}`); const j = await r.json(); if (!j.success) throw new Error(`${pfad} -> ${j.error}`); return j.data; } -function ledgerLesen() { - if (!existsSync(LEDGER)) return []; - return readFileSync(LEDGER, 'utf8') - .split('\n') - .filter((z) => z.trim()) - .map((z) => JSON.parse(z)); +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'; +if (gitStill('remote', 'get-url', REMOTE).ok) { + const f = gitStill('fetch', '--quiet', REMOTE); + if (!f.ok) console.warn(`HINWEIS: fetch von ${REMOTE} fehlgeschlagen – prüfe gegen lokalen Stand.\n${f.out}`); + else if (gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) ref = `${REMOTE}/${zweig}`; } -const bisher = ledgerLesen(); - -// --- seq lueckenlos und monoton pruefen ------------------------------------- -// Ohne diese Pruefung koennte jemand die Checkpoint-Reihe selbst hinten -// kuerzen; die Luecke faellt dann nicht auf. -for (let i = 0; i < bisher.length; i++) { - if (bisher[i].seq !== i + 1) { - console.error(`ALARM: Lücke in der Checkpoint-Reihe an Position ${i + 1} (seq=${bisher[i].seq}).`); - process.exit(2); +// 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) { + const schlecht = zeilen + .map((z) => z.split(' ')) + .filter(([, status]) => status !== 'G' && status !== 'U'); + // 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); + }); + 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'); -// --- Abgleich VOR dem Anhaengen --------------------------------------------- if (letzter) { - // 1) Abschneiden am Ende: der beglaubigte Stand darf nie schrumpfen. if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) { - console.error( - `ALARM: Das Audit-Log ist geschrumpft. Beglaubigt war maxId=${letzter.maxId}, ` + + alarm( + `Das Audit-Log ist geschrumpft. Beglaubigt war maxId=${letzter.maxId}, ` + `jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`, ); - process.exit(2); } - - // 2) Umschreiben: der frueher beglaubigte Kopf muss reproduzierbar bleiben. const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`); - if (rueck.atHash === null) { - console.error(`ALARM: Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`); - process.exit(2); - } + if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`); if (rueck.atHash !== letzter.chainHead) { - console.error( - `ALARM: Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` + + alarm( + `Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` + ` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`, ); - process.exit(2); } - - // 3) Bestandssiegel darf nicht klammheimlich verschwinden. if (letzter.sealRoot && !aktuell.sealRoot) { - console.error('ALARM: Das Bestandssiegel ist verschwunden (vorher beglaubigt vorhanden).'); - process.exit(2); + 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( @@ -115,23 +178,65 @@ if (letzter) { } } -// --- Anhaengen, signieren, pushen ------------------------------------------- +// --------------------------------------------------------------------------- +// 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}).` : '.'), + ); + process.exit(0); +} + const eintrag = { seq: bisher.length + 1, ts: aktuell.ts, maxId: aktuell.maxId, chainHead: aktuell.chainHead, sealRoot: aktuell.sealRoot, - sealStatus: aktuell.sealStatus, + sealLeafCount: aktuell.sealLeafCount, }; -writeFileSync(LEDGER, [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n'); +const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n'; -git('add', LEDGER); -// -S signiert den Commit mit dem Schluessel DIESES Rechners. -const commitArgs = ['commit', ...(SIGN ? ['-S'] : []), '-m', - `Checkpoint ${eintrag.seq} – maxId ${eintrag.maxId} – ${eintrag.ts}`]; -if (!SIGN) console.warn('WARNUNG: Commit wird NICHT signiert (NOTARY_SIGN=false).'); -git(...commitArgs); -if (PUSH) git('push'); +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' && st !== 'U') { + git('reset', '--hard', 'HEAD~1'); + alarm(`Der neue Commit trägt keine gültige Signatur (%G?=${st}). Zurückgerollt.`); + } +} +if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert – das Gegenbuch ist ungeschützt.'); + +if (PUSH) { + 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}).`);