From f3ded9afbc9a12d6a622316433406ba2212943e3 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 21 Aug 2026 15:20:55 +0200 Subject: [PATCH] Gegenbuch: externe Notarisierung der Audit-Kette Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in derselben Datenbank, die sie absichern sollen - der Pentest hat das ueber mehrere Runden Schicht fuer Schicht gezeigt, zuletzt in R174-01 am Siegel-Marker selbst. Aufteilung nach der Analyse des Pentesters (der Schutz kommt vom Ort, nicht von der Signatur): Das CRM liefert nur einen lesbaren Kontrollwert ohne Geheimnisse (GET /api/audit-logs/checkpoint, audit:read). Signiert, zeitgestempelt und angehaengt wird auf einem anderen Rechner - Schluessel und Push-Recht liegen nicht in den Deploy-Secrets des CRM. Ohne diese Trennung waere es D1 nochmal, nur schlimmer: sieht nach doppeltem Boden aus, tut still nichts. Der Kontrollwert enthaelt bewusst maxId. Ein blosser Kopf-Hash erkennt Umschreiben, aber kein Abschneiden am Ende - genau die R174-01-Klasse, eine Ebene hoeher. atId erlaubt der Gegenstelle, einen frueher beglaubigten Kopf erneut abzufragen und nachzurechnen. Gegenstelle: tools/audit-notary/notary.mjs (Cron auf zweitem Rechner, privates Git-Repo als Append-only-Ablage, signierte Commits). Prueft vor dem Anhaengen und bricht bei Widerspruch mit Exit-Code 2 ab, ohne zu schreiben. Verifiziert gegen eine CRM-Attrappe mit echter DB: beglaubigte Zeile veraendert -> Alarm; am Ende abgeschnitten (maxId 5->4) -> Alarm; Gegenbuch selbst gekuerzt (seq-Luecke) -> Alarm; in allen Faellen nichts angehaengt. Ehrlich dokumentiert: Restfenster zwischen zwei Laeufen bleibt und ist inhaerent; ein stiller Cron-Ausfall erzeugt im CRM keine Warnung und muss auf dem Gegenbuch-Rechner ueberwacht werden; Force-Push muss serverseitig gesperrt sein, sonst ist Append-only nur geliehen. Co-Authored-By: Claude Opus 5 --- .../src/controllers/auditLog.controller.ts | 23 +++ backend/src/routes/auditLog.routes.ts | 3 + backend/src/services/audit.service.ts | 63 ++++++++ docs/todo.md | 32 ++++ tools/audit-notary/README.md | 71 +++++++++ tools/audit-notary/notary.mjs | 137 ++++++++++++++++++ 6 files changed, 329 insertions(+) create mode 100644 tools/audit-notary/README.md create mode 100755 tools/audit-notary/notary.mjs diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index df08c72b..59c031e5 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -269,6 +269,29 @@ export async function rehashAll(req: AuthRequest, res: Response) { } } +/** + * Kontrollwert fuer das externe Gegenbuch (Notarisierung). + * + * Bewusst nur lesend und ohne Geheimnisse - die Gegenstelle braucht keinerlei + * Schreibrecht auf dieses System, und dieses System kennt den Schluessel der + * Gegenstelle nicht. Genau diese Trennung ist der Schutz. + */ +export async function getCheckpoint(req: AuthRequest, res: Response) { + try { + const roh = req.query.atId as string | undefined; + const atId = roh !== undefined ? parseInt(roh, 10) : undefined; + if (roh !== undefined && Number.isNaN(atId as number)) { + res.status(400).json({ success: false, error: 'atId muss eine Zahl sein' }); + return; + } + const data = await auditService.getCheckpoint(atId); + res.json({ success: true, data }); + } catch (error) { + console.error('Fehler beim Erstellen des Kontrollwerts:', error); + res.status(500).json({ success: false, error: 'Fehler beim Erstellen des Kontrollwerts' }); + } +} + /** * Altbestand versiegeln (Pentest R171-02). * diff --git a/backend/src/routes/auditLog.routes.ts b/backend/src/routes/auditLog.routes.ts index 54f211aa..89905533 100644 --- a/backend/src/routes/auditLog.routes.ts +++ b/backend/src/routes/auditLog.routes.ts @@ -22,6 +22,9 @@ router.get('/export', requirePermission('audit:read'), auditLogController.export router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies); router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy); +// Kontrollwert für das externe Gegenbuch (nur lesend, ohne Geheimnisse) +router.get('/checkpoint', requirePermission('audit:read'), auditLogController.getCheckpoint); + // Audit-Logs für einen Kunden (DSGVO) router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer); diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index ab229702..d533c838 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -676,6 +676,69 @@ const BACKLOG_SEAL_RESOURCE = 'AuditBacklogSeal'; * dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze * ab (sie MUESSEN V3 sein), es bleibt also keine Naht dazwischen. */ +/** + * Kontrollwert fuer die externe Notarisierung (Gegenbuch). + * + * Alle bisherigen Anker leben IM System: Hash, Versionsgrenze, Manifest, + * Bestandssiegel, Siegel-Marker. Wer Schreibzugriff auf die Datenbank hat, + * sitzt am Ende immer schon auf der Ebene, die beweisen soll – zuletzt gezeigt + * in R174-01. Ein Gegenbuch an einem fremden Ort durchbricht das. + * + * Der Wert enthaelt bewusst `maxId`: Ein blosser Kopf-Hash erkennt zwar + * Umschreiben, aber kein ABSCHNEIDEN am Ende. Mit ankertem `maxId` ist + * „juengster beglaubigter maxId > aktueller maxId“ der Beweis, dass Zeilen + * verschwunden sind. + * + * `atId` erlaubt der Gegenstelle, einen FRUEHER beglaubigten Kopf erneut + * abzufragen und zu reproduzieren – ohne das koennte sie nur den aktuellen + * Stand sehen und nichts nachrechnen. + * + * Enthaelt keine Geheimnisse: nur Hashes und Zaehlstaende. + */ +export async function getCheckpoint(atId?: number): Promise<{ + ts: string; + maxId: number | null; + chainHead: string | null; + sealRoot: string | null; + sealStatus: string; + atId?: number; + atHash?: string | null; +}> { + const kopf = await prisma.auditLog.findFirst({ + orderBy: { id: 'desc' }, + select: { id: true, hash: true }, + }); + const pruefung = await verifyIntegrity(); + + const ergebnis: { + ts: string; + maxId: number | null; + chainHead: string | null; + sealRoot: string | null; + sealStatus: string; + atId?: number; + atHash?: string | null; + } = { + ts: new Date().toISOString(), + maxId: kopf?.id ?? null, + chainHead: kopf?.hash ?? null, + sealRoot: pruefung.backlogSealRoot, + sealStatus: pruefung.backlogSealStatus, + }; + + if (atId !== undefined && Number.isFinite(atId)) { + const zeile = await prisma.auditLog.findUnique({ + where: { id: atId }, + select: { hash: true }, + }); + ergebnis.atId = atId; + // null bedeutet: die beglaubigte Zeile existiert nicht mehr. + ergebnis.atHash = zeile?.hash ?? null; + } + + return ergebnis; +} + export async function sealBacklog( ausgeloestVon?: { userEmail?: string; ipAddress?: string }, ): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> { diff --git a/docs/todo.md b/docs/todo.md index e9f4c375..36019207 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,38 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [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 + mehrere Runden Schicht fuer Schicht gezeigt, zuletzt in R174-01 am Siegel- + Marker selbst. Ein Gegenbuch an einem fremden Ort durchbricht das. + - Aufteilung nach der Analyse des Pentesters („der Schutz kommt vom ORT, + nicht von der Signatur“): Das CRM liefert nur einen **lesbaren + Kontrollwert ohne Geheimnisse** (`GET /api/audit-logs/checkpoint`, + `audit:read`). Signiert, zeitstempelt und angehaengt wird auf einem + **anderen Rechner** – Schluessel und Push-Recht liegen NICHT in den + Deploy-Secrets des CRM. Ohne diese Trennung waere es „D1 nochmal, nur + schlimmer“: sieht nach doppeltem Boden aus, tut still nichts. + - Der Kontrollwert enthaelt bewusst `maxId`. Ein blosser Kopf-Hash erkennt + Umschreiben, aber kein **Abschneiden am Ende** – genau die R174-01-Klasse, + eine Ebene hoeher. `atId` erlaubt der Gegenstelle, einen frueher + beglaubigten Kopf erneut abzufragen und nachzurechnen. + - Gegenstelle: `tools/audit-notary/notary.mjs` (Cron auf zweitem Rechner, + privates Git-Repo als Append-only-Ablage, signierte Commits). Prueft VOR + dem Anhaengen und bricht bei Widerspruch mit Exit-Code 2 ab, **ohne zu + schreiben** – der manipulierte Zustand wird nicht als neue Wahrheit + festgeschrieben. + - Verifiziert gegen eine CRM-Attrappe mit echter DB: beglaubigte Zeile + veraendert → Alarm; am Ende abgeschnitten (maxId 5→4) → Alarm; Gegenbuch + selbst gekuerzt (seq-Luecke) → Alarm; in allen Faellen nichts angehaengt. + Wegwerf-DB und Test-Gegenbuch danach geloescht. + - **Ehrlich dokumentiert** (README): Restfenster zwischen zwei Laeufen bleibt + und ist inhaerent; ein stiller Cron-Ausfall erzeugt im CRM KEINE Warnung + und muss auf dem Gegenbuch-Rechner ueberwacht werden; Force-Push muss + serverseitig gesperrt sein, sonst ist Append-only nur geliehen. + - **Betrieb:** Einrichtung liegt beim Betreiber (privates Repo, Cron, + Signaturschluessel auf dem zweiten Rechner). + - [x] **🚨 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)** (2026-08-18) - Fund: Der Siegelzustand hing **ausschliesslich** am Marker im Audit-Log – und den kann ein DB-Schreiber **ohne Schluessel** loeschen. Danach meldete diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md new file mode 100644 index 00000000..3730e316 --- /dev/null +++ b/tools/audit-notary/README.md @@ -0,0 +1,71 @@ +# Gegenbuch (externe Notarisierung) + +Alle Schutzmechanismen im CRM selbst – Hash-Kette, Versionsgrenze, +Löschungs-Manifest, Bestandssiegel – liegen in **derselben Datenbank**, die sie +absichern sollen. Wer dort schreiben kann, sitzt am Ende immer schon auf der +Ebene, die den Beweis führt. Genau das hat der Pentest über mehrere Runden +Schicht für Schicht gezeigt. + +Das Gegenbuch durchbricht das: Ein zweiter Rechner holt regelmäßig einen kurzen +Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und hängt ihn +signiert an ein privates Repository an. + +## Die eine nicht verhandelbare Bedingung + +**Der Signaturschlüssel und das Push-Recht dürfen nicht in den Deploy-Secrets +des CRM liegen.** Sonst ist der zweite Schlüssel im selben Zugriffsbereich wie +der erste – es sieht nach doppeltem Boden aus und ist keiner. Das CRM braucht +für dieses Verfahren **gar nichts** zu wissen: Es liefert nur einen lesbaren +Kontrollwert, der keine Geheimnisse enthält. + +## Einrichten + +Auf einem **anderen** Rechner als dem CRM-Server: + +```bash +git clone git@host:dein/audit-gegenbuch.git +cd audit-gegenbuch + +export CRM_URL=https://crm.example.de +export CRM_TOKEN= + +node /pfad/zu/notary.mjs +``` + +Als Cronjob, stündlich: + +```cron +0 * * * * cd /pfad/zum/gegenbuch && CRM_URL=https://crm.example.de \ + CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1 +``` + +**Force-Push serverseitig sperren.** Ohne das ist die Append-only-Eigenschaft +nur geliehen – wer Schreibrecht auf das Repository erlangt, schreibt die +Historie sonst einfach um. Bei GitHub/GitLab: Branch-Protection auf `main`. + +## Was das Skript erkennt + +| Angriff | Erkennung | +|---|---| +| Beglaubigter Eintrag nachträglich verändert | Hash an der beglaubigten Position stimmt nicht mehr | +| 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 | + +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 +festgeschrieben. Für Cron heißt das: Exit-Code überwachen, sonst geht der Alarm +im Log unter. + +## Was es nicht leistet – ehrlich + +- **Restfenster:** Einträge zwischen dem letzten Kontrollwert und jetzt sind + noch nicht beglaubigt und am Ende fälsch- oder kürzbar. Häufigerer Lauf + verkleinert das Fenster, beseitigt es aber nicht. Das gilt für jedes + periodische Verfahren dieser Art. +- **Stiller Ausfall:** Läuft der Cronjob nicht mehr, entsteht keine Warnung im + 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. diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs new file mode 100755 index 00000000..dc1457df --- /dev/null +++ b/tools/audit-notary/notary.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * 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. + * + * 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. + * + * 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 + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } 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 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'; + +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(); + +async function hole(pfad) { + 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 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); + } +} + +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}, ` + + `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 !== letzter.chainHead) { + console.error( + `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); + } + 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.`, + ); + } +} + +// --- Anhaengen, signieren, pushen ------------------------------------------- +const eintrag = { + seq: bisher.length + 1, + ts: aktuell.ts, + maxId: aktuell.maxId, + chainHead: aktuell.chainHead, + sealRoot: aktuell.sealRoot, + sealStatus: aktuell.sealStatus, +}; +writeFileSync(LEDGER, [...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'); + +console.log(`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).`);