Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)

R175-01 (HIGH): Die erste Fassung signierte zwar, prueft aber nie. Sie las ihre
Wahrheit per readFileSync aus der lokalen Arbeitsdatei, nirgends 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.

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.

R175-02 (MEDIUM): /checkpoint fuhr je Aufruf ein volles verifyIntegrity (O(n),
0,85 s bei 16k Zeilen) - authentifizierte DoS-Verstaerkung. Gebraucht wurde nur
die Siegel-Wurzel. Jetzt Kopf-Hash aus der Kopfzeile, Wurzel aus dem juengsten
gueltigen Marker, sealLeafCount statt des teuren Status.

R175-03: writeFileSync lief vor dem Commit, eine verwaiste Zeile wurde vom
Folgelauf zementiert. Jetzt Ruecknahme bei Fehlschlag, und NOTARY_SIGN=false
verlangt zusaetzlich NOTARY_INSECURE_ACK.

Verifiziert mit echtem SSH-Signaturschluessel: stilles Waschen -> Alarm;
erfundene Zeile -> Alarm; unsignierter Commit -> Alarm; Commit-Fehlschlag ->
zurueckgerollt; Reflex-Schalter verweigert; Pruefmodus haengt nichts an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 16:13:54 +02:00
co-authored by Claude Opus 5
parent f3ded9afbc
commit 375d4ae1e2
4 changed files with 293 additions and 76 deletions
+43 -5
View File
@@ -700,7 +700,7 @@ export async function getCheckpoint(atId?: number): Promise<{
maxId: number | null; maxId: number | null;
chainHead: string | null; chainHead: string | null;
sealRoot: string | null; sealRoot: string | null;
sealStatus: string; sealLeafCount: number;
atId?: number; atId?: number;
atHash?: string | null; atHash?: string | null;
}> { }> {
@@ -708,22 +708,60 @@ export async function getCheckpoint(atId?: number): Promise<{
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
select: { id: true, hash: true }, 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: { const ergebnis: {
ts: string; ts: string;
maxId: number | null; maxId: number | null;
chainHead: string | null; chainHead: string | null;
sealRoot: string | null; sealRoot: string | null;
sealStatus: string; sealLeafCount: number;
atId?: number; atId?: number;
atHash?: string | null; atHash?: string | null;
} = { } = {
ts: new Date().toISOString(), ts: new Date().toISOString(),
maxId: kopf?.id ?? null, maxId: kopf?.id ?? null,
chainHead: kopf?.hash ?? null, chainHead: kopf?.hash ?? null,
sealRoot: pruefung.backlogSealRoot, sealRoot,
sealStatus: pruefung.backlogSealStatus, // Reicht der Gegenstelle, um das Verschwinden des Siegels zu bemerken:
// Blaetter ohne Wurzel = Marker entfernt.
sealLeafCount: blattAnzahl,
}; };
if (atId !== undefined && Number.isFinite(atId)) { if (atId !== undefined && Number.isFinite(atId)) {
+38
View File
@@ -97,6 +97,44 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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) - [x] **⚖️ Gegenbuch: externe Notarisierung der Audit-Kette** (2026-08-18)
- Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in - Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in
derselben Datenbank, die sie absichern sollen der Pentest hat das ueber derselben Datenbank, die sie absichern sollen der Pentest hat das ueber
+38 -2
View File
@@ -43,6 +43,32 @@ Als Cronjob, stündlich:
nur geliehen wer Schreibrecht auf das Repository erlangt, schreibt die nur geliehen wer Schreibrecht auf das Repository erlangt, schreibt die
Historie sonst einfach um. Bei GitHub/GitLab: Branch-Protection auf `main`. 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 ## Was das Skript erkennt
| Angriff | Erkennung | | 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 | | Einträge am Ende abgeschnitten | aktuelle höchste ID kleiner als die beglaubigte |
| Bestandssiegel verschwunden | vorher beglaubigt, jetzt nicht mehr vorhanden | | Bestandssiegel verschwunden | vorher beglaubigt, jetzt nicht mehr vorhanden |
| Gegenbuch selbst gekürzt | Lücke in der fortlaufenden Nummer | | 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 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 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 CRM dort ist ja nichts konfiguriert. Der Ausfall muss auf dem
Gegenbuch-Rechner überwacht werden (z. B. Alarm, wenn der jüngste Commit Gegenbuch-Rechner überwacht werden (z. B. Alarm, wenn der jüngste Commit
älter als zwei Intervalle ist). älter als zwei Intervalle ist).
- **`NOTARY_SIGN=false`** existiert nur für Tests ohne hinterlegten Schlüssel. - **`NOTARY_SIGN=false`** existiert nur für Tests ohne hinterlegten Schlüssel
Im Betrieb niemals setzen. 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.
+174 -69
View File
@@ -3,109 +3,172 @@
* OpenCRM Gegenbuch (externe Notarisierung) * OpenCRM Gegenbuch (externe Notarisierung)
* =========================================== * ===========================================
* *
* WICHTIG: Dieses Skript laeuft NICHT auf dem CRM-Server. Es gehoert auf einen * Laeuft NICHT auf dem CRM-Server, sondern auf einem zweiten Rechner. Der
* zweiten Rechner. Der Signaturschluessel und das Push-Recht auf das private * Signaturschluessel und das Push-Recht leben dort nicht in den
* Repository leben dort nicht in den Deploy-Secrets des CRM. * Deploy-Secrets des CRM.
* *
* Der Grund: Alle Anker im CRM selbst (Hash-Kette, Versionsgrenze, Manifest, * ZENTRALE LEHRE (Pentest R175-01): Append-only und Signieren allein reichen
* Bestandssiegel) sitzen in derselben Datenbank, die sie schuetzen sollen. Wer * nicht es braucht einen VERIFIZIERENDEN LESER. Die erste Fassung las ihre
* dort schreiben kann, kommt am Ende an jeden davon heran. Ein Gegenbuch an * Wahrheit aus der lokalen Arbeitsdatei und hat nie eine Signatur geprueft;
* einem fremden Ort durchbricht das aber NUR, solange der Schluessel dieses * das `-S` war write-only ohne Konsument. Wer den Notar-Host beschreiben
* Ortes nicht im selben Zugriffsbereich liegt. Liegt er es doch, sieht es nach * konnte, hat dem Skript beliebige „beglaubigte“ Zeilen untergeschoben und den
* doppeltem Boden aus und ist keiner. * abgeschnittenen Zustand als neue Wahrheit zementiert ohne Alarm.
* *
* Ablauf je Lauf: * Deshalb gilt jetzt:
* 1. Kontrollwert beim CRM abholen (nur lesend) * - Wahrheitsquelle ist der SIGNIERTE Commit-Baum (bevorzugt der Remote-Kopf),
* 2. GEGEN die eigene Historie pruefen, BEVOR etwas angehaengt wird * nicht die Arbeitsdatei.
* 3. Nur bei Unauffaelligkeit anhaengen, signiert committen, pushen * - JEDER Commit der Historie muss eine gute Signatur tragen.
* 4. Bei Widerspruch: Exit-Code 2 und nichts schreiben * - Weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen.
* * - Geschrieben wird erst nach erfolgreichem, signiertem Commit; schlaegt er
* Aufruf (Beispiel-Cron, stuendlich): * fehl, wird die Datei zurueckgerollt (Pentest R175-03).
* 0 * * * * cd /pfad/zum/gegenbuch && CRM_URL=https://crm.example.de \
* CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1
*/ */
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs'; 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_URL = process.env.CRM_URL;
const CRM_TOKEN = process.env.CRM_TOKEN; const CRM_TOKEN = process.env.CRM_TOKEN;
const LEDGER = resolve(process.env.LEDGER_FILE || 'checkpoints.jsonl'); const LEDGER = resolve(process.env.LEDGER_FILE || 'checkpoints.jsonl');
const LEDGER_NAME = basename(LEDGER);
const PUSH = process.env.NOTARY_PUSH !== 'false'; const PUSH = process.env.NOTARY_PUSH !== 'false';
// NUR fuer Tests ohne hinterlegten Schluessel. Im Betrieb NIEMALS setzen: ohne const REMOTE = process.env.NOTARY_REMOTE || 'origin';
// Signatur kann jeder, der Schreibrecht auf das Repository erlangt, die // Reiner Pruefmodus fuer Auditoren: fuehrt alle Kontrollen aus, haengt aber
// Checkpoint-Reihe faelschen - dann ist das Gegenbuch wertlos. // nichts an und braucht kein Schreibrecht.
const SIGN = process.env.NOTARY_SIGN !== 'false'; 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) { if (!CRM_URL || !CRM_TOKEN) {
console.error('CRM_URL und CRM_TOKEN müssen gesetzt sein.'); console.error('CRM_URL und CRM_TOKEN müssen gesetzt sein.');
process.exit(1); process.exit(1);
} }
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); 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) { async function hole(pfad) {
const r = await fetch(`${CRM_URL}${pfad}`, { const r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } });
headers: { Authorization: `Bearer ${CRM_TOKEN}` },
});
if (!r.ok) throw new Error(`${pfad} -> HTTP ${r.status}`); if (!r.ok) throw new Error(`${pfad} -> HTTP ${r.status}`);
const j = await r.json(); const j = await r.json();
if (!j.success) throw new Error(`${pfad} -> ${j.error}`); if (!j.success) throw new Error(`${pfad} -> ${j.error}`);
return j.data; return j.data;
} }
function ledgerLesen() { const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); };
if (!existsSync(LEDGER)) return [];
return readFileSync(LEDGER, 'utf8') // ---------------------------------------------------------------------------
.split('\n') // 1) Vertrauenswuerdigen Stand bestimmen: bevorzugt der Remote-Kopf.
.filter((z) => z.trim()) // ---------------------------------------------------------------------------
.map((z) => JSON.parse(z)); 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(); // Lokale Commits, die dem Remote voraus sind, sind nicht beglaubigt.
if (ref !== 'HEAD') {
// --- seq lueckenlos und monoton pruefen ------------------------------------- const voraus = gitStill('rev-list', '--count', `${ref}..HEAD`);
// Ohne diese Pruefung koennte jemand die Checkpoint-Reihe selbst hinten if (voraus.ok && Number(voraus.out) > 0) {
// kuerzen; die Luecke faellt dann nicht auf. alarm(
for (let i = 0; i < bisher.length; i++) { `${voraus.out} lokale(r) Commit(s) wurden nie gepusht. Das Gegenbuch ist damit ` +
if (bisher[i].seq !== i + 1) { `nur lokal entweder pushen oder untersuchen, wie sie entstanden sind.`,
console.error(`ALARM: Lücke in der Checkpoint-Reihe an Position ${i + 1} (seq=${bisher[i].seq}).`); );
process.exit(2);
} }
} }
// ---------------------------------------------------------------------------
// 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 letzter = bisher[bisher.length - 1];
const aktuell = await hole('/api/audit-logs/checkpoint'); const aktuell = await hole('/api/audit-logs/checkpoint');
// --- Abgleich VOR dem Anhaengen ---------------------------------------------
if (letzter) { if (letzter) {
// 1) Abschneiden am Ende: der beglaubigte Stand darf nie schrumpfen.
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) { if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
console.error( alarm(
`ALARM: Das Audit-Log ist geschrumpft. Beglaubigt war maxId=${letzter.maxId}, ` + `Das Audit-Log ist geschrumpft. Beglaubigt war maxId=${letzter.maxId}, ` +
`jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`, `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}`); const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
if (rueck.atHash === null) { if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
console.error(`ALARM: Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
process.exit(2);
}
if (rueck.atHash !== letzter.chainHead) { if (rueck.atHash !== letzter.chainHead) {
console.error( alarm(
`ALARM: Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` + `Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`, ` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
); );
process.exit(2);
} }
// 3) Bestandssiegel darf nicht klammheimlich verschwinden.
if (letzter.sealRoot && !aktuell.sealRoot) { if (letzter.sealRoot && !aktuell.sealRoot) {
console.error('ALARM: Das Bestandssiegel ist verschwunden (vorher beglaubigt vorhanden).'); alarm('Das Bestandssiegel ist verschwunden (vorher beglaubigt vorhanden).');
process.exit(2); }
if (letzter.sealLeafCount > 0 && aktuell.sealLeafCount === 0) {
alarm('Die Blattwerte des Bestandssiegels wurden entfernt.');
} }
if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) { if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) {
console.warn( 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 = { const eintrag = {
seq: bisher.length + 1, seq: bisher.length + 1,
ts: aktuell.ts, ts: aktuell.ts,
maxId: aktuell.maxId, maxId: aktuell.maxId,
chainHead: aktuell.chainHead, chainHead: aktuell.chainHead,
sealRoot: aktuell.sealRoot, 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); const zuruecknehmen = () => {
// -S signiert den Commit mit dem Schluessel DIESES Rechners. // Ohne das bliebe eine uncommittete Zeile liegen, die der naechste Lauf
const commitArgs = ['commit', ...(SIGN ? ['-S'] : []), '-m', // absorbiert und damit zementiert (Pentest R175-03).
`Checkpoint ${eintrag.seq} maxId ${eintrag.maxId} ${eintrag.ts}`]; const r = gitStill('checkout', ref, '--', LEDGER_NAME);
if (!SIGN) console.warn('WARNUNG: Commit wird NICHT signiert (NOTARY_SIGN=false).'); if (!r.ok && !ausBaum.ok) writeFileSync(LEDGER, '');
git(...commitArgs); };
if (PUSH) git('push');
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}).`); console.log(`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).`);