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:
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
Reference in New Issue
Block a user