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
+63
View File
@@ -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 }> {