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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:20:55 +02:00
co-authored by Claude Opus 5
parent 2d55fd23f9
commit f3ded9afbc
6 changed files with 329 additions and 0 deletions
@@ -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). * Altbestand versiegeln (Pentest R171-02).
* *
+3
View File
@@ -22,6 +22,9 @@ router.get('/export', requirePermission('audit:read'), auditLogController.export
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies); router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy); 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) // Audit-Logs für einen Kunden (DSGVO)
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer); router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
+63
View File
@@ -676,6 +676,69 @@ const BACKLOG_SEAL_RESOURCE = 'AuditBacklogSeal';
* dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze * dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze
* ab (sie MUESSEN V3 sein), es bleibt also keine Naht dazwischen. * 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( export async function sealBacklog(
ausgeloestVon?: { userEmail?: string; ipAddress?: string }, ausgeloestVon?: { userEmail?: string; ipAddress?: string },
): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> { ): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> {
+32
View File
@@ -97,6 +97,38 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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) - [x] **🚨 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)** (2026-08-18)
- Fund: Der Siegelzustand hing **ausschliesslich** am Marker im Audit-Log - Fund: Der Siegelzustand hing **ausschliesslich** am Marker im Audit-Log
und den kann ein DB-Schreiber **ohne Schluessel** loeschen. Danach meldete und den kann ein DB-Schreiber **ohne Schluessel** loeschen. Danach meldete
+71
View File
@@ -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=<Access-Token eines Benutzers mit audit:read>
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.
+137
View File
@@ -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}).`);