From 2932598c98a89b034fefe709ba14e3dad1e26f80 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 19 Aug 2026 22:12:51 +0200 Subject: [PATCH] Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02) Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine LOGIN_FAILED-Zeile auf success=1 setzen, das Label umschreiben und errorMessage leeren - alles Nicht-Hash-Felder, Hash unveraendert - und /verify meldete weiterhin valid=true. Ein Einbruchsversuch war unsichtbar in einen Erfolg umschreibbar. Rueckwirkend signieren geht nicht, ein Rehash waere die falsche Medizin. Stattdessen ein einmaliges, nicht destruktives Bestandssiegel: je Altzeile ein Blattwert, die Wurzel darueber in einem HMAC-signierten Marker. Umgesetzt nach den vier Bedingungen aus dem Pentest: 1. Blaetter ueber den vollen Zeileninhalt inkl. id und hashVersion, nicht ueber den 7-Feld-V1-Hash - sonst lebte die Luecke im Siegel weiter. 2. Wurzel signiert (steht im Marker, der selbst V3/HMAC ist). Ohne AUDIT_HMAC_KEY wird das Siegeln abgelehnt. 3. Bereich fix auf [1 ... v3FromId-1] statt Live-Abfrage hashVersion < 3. Sonst haette ein Up-Flip der Grenzzeile sie aus der geprueften Menge gedraengt. 4. Pruefung je id: vorhanden, weiterhin Altbestand, Inhalt == Blatt, dazu Wurzelabgleich. Neuer Endpunkt POST /audit-logs/seal-backlog (audit:admin, confirm SEAL). /verify meldet den Siegelzustand im Klartext, auch wenn kein Siegel existiert. Verifiziert in separater Wegwerf-DB mit Mischbestand (6xV1, 5xV2, 5xV3): ohne Siegel ist der Angriff unsichtbar, mit Siegel wird er erkannt und die Zeile benannt; der Up-Flip der Grenzzeile wird ebenfalls erkannt. Wegwerf-DB danach geloescht, Dev-Daten unberuehrt. tsc + vite build gruen. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 16 ++ backend/prisma/schema.prisma | 17 ++ .../src/controllers/auditLog.controller.ts | 49 ++++- backend/src/routes/auditLog.routes.ts | 3 + backend/src/services/audit.service.ts | 180 ++++++++++++++++++ docs/todo.md | 36 ++++ 6 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 backend/prisma/migrations/20260818170000_audit_backlog_seal/migration.sql diff --git a/backend/prisma/migrations/20260818170000_audit_backlog_seal/migration.sql b/backend/prisma/migrations/20260818170000_audit_backlog_seal/migration.sql new file mode 100644 index 00000000..0e00277b --- /dev/null +++ b/backend/prisma/migrations/20260818170000_audit_backlog_seal/migration.sql @@ -0,0 +1,16 @@ +-- Bestandssiegel fuer den nicht signierbaren Altbestand (Pentest R171-02). +-- +-- Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein Schreibzugriff auf +-- die Datenbank kann dort Felder wie `success` oder `resourceLabel` aendern, +-- ohne den gespeicherten Hash zu beruehren - die Pruefung meldet weiterhin +-- "gueltig". Live gezeigt: ein fehlgeschlagener Login liess sich unsichtbar in +-- einen erfolgreichen umschreiben. +-- +-- Rueckwirkend signieren geht nicht. Stattdessen wird der Altbestand einmalig +-- versiegelt: je Zeile ein Blattwert ueber den vollen Inhalt, die Wurzel +-- darueber in einem HMAC-signierten Marker. +CREATE TABLE IF NOT EXISTS `AuditBacklogSeal` ( + `auditLogId` INT NOT NULL, + `leafHash` VARCHAR(191) NOT NULL, + PRIMARY KEY (`auditLogId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8119e73f..bd0d505e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1274,6 +1274,23 @@ model RefreshTokenRecord { @@index([customerId]) } +/// Blattwerte des Bestandssiegels (Pentest R171-02 / R172). +/// +/// Der Altbestand (Hash-Version 1/2) laesst sich nicht rueckwirkend signieren – +/// ein Siegel vergibt man beim Anlegen. Bei V1 sind nur 7 von 24 Spalten +/// gehasht, ein DB-Schreiber kann dort z. B. `success` von false auf true +/// setzen, ohne den Hash zu beruehren: die Pruefung meldet weiterhin "gueltig". +/// +/// Das Bestandssiegel schliesst das, ohne irgendetwas umzuschreiben: Zum +/// Zeitpunkt X wird je Altzeile ein Blattwert ueber den VOLLEN Inhalt +/// (alle Spalten inkl. id und hashVersion) gebildet; die Wurzel darueber +/// steht in einem HMAC-signierten Marker. Jede spaetere Aenderung an einer +/// Altzeile bricht Blatt und Wurzel. +model AuditBacklogSeal { + auditLogId Int @id + leafHash String +} + enum AuditAction { CREATE READ diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index e3e49035..de846815 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -164,6 +164,13 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { ? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.` : ''; + const siegel = + result.backlogSealStatus === 'gebrochen' + ? ` ⚠ Bestandssiegel GEBROCHEN: ${result.backlogTampered.length} Alteinträge wurden nachträglich verändert.` + : result.backlogSealStatus === 'kein_siegel' + ? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar.' + : ''; + const message = tampered > 0 ? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '') : gaps > 0 @@ -183,8 +190,11 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { // Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig. unexplainedGaps: result.unexplainedGaps, tampered: tampered > 0, - message: message + keinSchluessel, + message: message + keinSchluessel + siegel, unverifiableEntries: result.unverifiableEntries, + // Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. + backlogSealStatus: result.backlogSealStatus, + backlogTampered: result.backlogTampered, }, }); } catch (error) { @@ -229,6 +239,43 @@ export async function rehashAll(req: AuthRequest, res: Response) { } } +/** + * Altbestand versiegeln (Pentest R171-02). + * + * Einmalige, NICHT destruktive Aktion: schreibt nichts um, sondern haelt den + * Ist-Inhalt der nicht signierbaren Alteintraege fest. + */ +export async function sealBacklog(req: AuthRequest, res: Response) { + try { + if (req.body?.confirm !== 'SEAL') { + res.status(400).json({ + success: false, + error: + 'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' + + '{"confirm":"SEAL"} mitsenden.', + }); + return; + } + const result = await auditService.sealBacklog({ + userEmail: req.user?.email, + ipAddress: req.ip || (req.socket as any)?.remoteAddress, + }); + res.json({ + success: true, + data: result, + message: + `${result.sealedCount} Alteinträge (id ${result.fromId}–${result.toId}) versiegelt. ` + + 'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.', + }); + } catch (error) { + console.error('Fehler beim Versiegeln des Altbestands:', error); + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Versiegeln', + }); + } +} + /** * Retention-Policies abrufen */ diff --git a/backend/src/routes/auditLog.routes.ts b/backend/src/routes/auditLog.routes.ts index 50c20fc2..54f211aa 100644 --- a/backend/src/routes/auditLog.routes.ts +++ b/backend/src/routes/auditLog.routes.ts @@ -31,6 +31,9 @@ router.post('/verify', requirePermission('audit:read'), auditLogController.verif // Hash-Kette reparieren router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll); +// Altbestand versiegeln (nicht destruktiv) +router.post('/seal-backlog', requirePermission('audit:admin'), auditLogController.sealBacklog); + // Retention-Cleanup manuell ausführen router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup); diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index ab3b7696..70cdcfcd 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -615,6 +615,112 @@ export async function getAuditLogsByDataSubject(customerId: number) { /** * Verifiziert die Integrität der Hash-Kette */ +/** + * Blattwert einer Altbestands-Zeile fuer das Bestandssiegel (Pentest R171-02). + * + * Bewusst ueber den VOLLEN Zeileninhalt inkl. `id` und `hashVersion` – nicht + * ueber den gespeicherten 7-Feld-Hash der Version 1. Sonst lebte die Luecke im + * Siegel weiter: genau die 17 ungehashten Spalten waeren wieder frei + * editierbar. `hashVersion` gehoert mit hinein, damit ein Hochstufen der Zeile + * (V1→V2, ohne Schluessel nachrechenbar) das Blatt bricht, statt sie klammheimlich + * aus dem Siegelbereich zu schieben. + */ +function backlogLeaf(row: Record): string { + const feld = (v: unknown) => + v instanceof Date ? v.toISOString() : v === undefined ? null : v; + // Feste Reihenfolge – Teil des Blattwerts. + const payload = [ + 'id', 'userId', 'userEmail', 'userRole', 'customerId', 'isCustomerPortal', + 'action', 'sensitivity', 'resourceType', 'resourceId', 'resourceLabel', + 'endpoint', 'httpMethod', 'ipAddress', 'userAgent', 'changesBefore', + 'changesAfter', 'changesEncrypted', 'dataSubjectId', 'legalBasis', 'success', + 'errorMessage', 'durationMs', 'createdAt', 'hash', 'previousHash', + 'hashVersion', + ].map((k) => [k, feld(row[k])]); + + return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +} + +/** Wurzel ueber die Blaetter in id-Reihenfolge. */ +function backlogRoot(leaves: Array<{ auditLogId: number; leafHash: string }>): string { + const sortiert = [...leaves].sort((a, b) => a.auditLogId - b.auditLogId); + return crypto + .createHash('sha256') + .update(JSON.stringify(sortiert.map((l) => [l.auditLogId, l.leafHash]))) + .digest('hex'); +} + +/** Endpoint-Kennung des Siegel-Markers (dient zugleich als Suchschluessel). */ +const BACKLOG_SEAL_ENDPOINT = '/api/audit-logs/seal-backlog'; + +/** + * Versiegelt den Altbestand einmalig. + * + * Der Bereich ist FIX auf `[1 … v3FromId-1]` gebunden, nicht auf eine + * Live-Abfrage `hashVersion < 3`. Sonst koennte ein Angreifer die Grenzzeile + * hochstufen und sie damit aus der geprueften Menge draengen – sie erschiene + * dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze + * ab (sie MUESSEN V3 sein), es bleibt also keine Naht dazwischen. + */ +export async function sealBacklog( + ausgeloestVon?: { userEmail?: string; ipAddress?: string }, +): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> { + if (!auditHmacKey()) { + throw new Error( + 'Bestandssiegel erfordert einen konfigurierten AUDIT_HMAC_KEY – ohne ihn liesse sich die Wurzel nachrechnen.', + ); + } + const c3 = await prisma.auditLog.aggregate({ + where: { hashVersion: { gte: 3 } }, + _min: { id: true }, + }); + const v3FromId = c3._min.id; + if (v3FromId === null) { + throw new Error( + 'Noch keine signierten Einträge vorhanden – bitte zuerst AUDIT_HMAC_KEY setzen und das Backend neu starten.', + ); + } + const toId = v3FromId - 1; + if (toId < 1) { + throw new Error('Kein Altbestand vorhanden – es gibt nichts zu versiegeln.'); + } + + const rows = await prisma.auditLog.findMany({ + where: { id: { lte: toId } }, + orderBy: { id: 'asc' }, + }); + const leaves = rows.map((r) => ({ + auditLogId: r.id, + leafHash: backlogLeaf(r as unknown as Record), + })); + const root = backlogRoot(leaves); + + await prisma.$transaction(async (tx) => { + await tx.auditBacklogSeal.deleteMany({}); + for (let i = 0; i < leaves.length; i += 500) { + await tx.auditBacklogSeal.createMany({ data: leaves.slice(i, i + 500) }); + } + }, { timeout: 60000, maxWait: 20000 }); + + // Marker ist selbst HMAC-signiert (Version 3) – damit ist die Wurzel + // signiert, ohne dass es einen zweiten Signaturpfad braucht. + await createAuditLog({ + userEmail: ausgeloestVon?.userEmail || 'system', + userRole: 'System', + action: 'CREATE', + resourceType: 'AuditLog', + resourceLabel: `Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1–${toId})`, + endpoint: BACKLOG_SEAL_ENDPOINT, + httpMethod: 'POST', + ipAddress: ausgeloestVon?.ipAddress || 'system', + sensitivity: 'CRITICAL', + changesAfter: { fromId: 1, toId, count: leaves.length, root }, + success: true, + }); + + return { sealedCount: leaves.length, fromId: 1, toId, root }; +} + export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ valid: boolean; checkedCount: number; @@ -643,6 +749,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ * eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log. */ unverifiableEntries: number[]; + /** + * Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. + * `kein_siegel` = nie erstellt (oder Marker nicht pruefbar). + */ + backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen'; + /** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */ + backlogTampered: number[]; }> { const where: Prisma.AuditLogWhereInput = {}; @@ -769,6 +882,71 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ const unexplainedGaps: number[] = []; const unverifiableEntries: number[] = []; + // --------------------------------------------------------------------- + // Bestandssiegel pruefen (Pentest R171-02) + // + // Der Altbestand ist nicht signierbar: bei Version 1 sind nur 7 von 24 + // Spalten gehasht. Wurde er versiegelt, wird hier je Zeile geprueft: + // vorhanden, immer noch Altbestand (hashVersion < 3) und Inhalt unveraendert. + // Der Marker muss selbst signiert und gueltig sein - sonst waere das Siegel + // ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01). + const backlogTampered: number[] = []; + let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' = 'kein_siegel'; + + const siegelKandidaten = await prisma.auditLog.findMany({ + where: { resourceType: 'AuditLog', action: 'CREATE', endpoint: BACKLOG_SEAL_ENDPOINT }, + orderBy: { id: 'desc' }, + }); + const siegelSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter( + (k): k is string => !!k, + ); + const siegel = siegelSchluessel.length + ? siegelKandidaten.find( + (r) => + erwarteteVersion(r.id) === 3 && + r.hashVersion >= 3 && + siegelSchluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k)), + ) + : undefined; + + if (siegel) { + try { + const meta = JSON.parse(siegel.changesAfter || '{}'); + const bis: number = meta.toId; + const blaetter = await prisma.auditBacklogSeal.findMany(); + const erwartet = new Map(blaetter.map((b) => [b.auditLogId, b.leafHash])); + + const altzeilen = await prisma.auditLog.findMany({ + where: { id: { lte: bis } }, + orderBy: { id: 'asc' }, + }); + const vorhanden = new Map(altzeilen.map((r) => [r.id, r])); + + for (const [id, blatt] of erwartet) { + const zeile = vorhanden.get(id); + // Fehlende Zeile schlaegt bereits als Ketten-Luecke auf; hier zaehlt + // die inhaltliche Veraenderung. + if (!zeile) continue; + if (zeile.hashVersion >= 3 || backlogLeaf(zeile as unknown as Record) !== blatt) { + backlogTampered.push(id); + } + } + + // Wurzel gegenrechnen: faengt auch Zeilen ab, die dem Siegel + // nachtraeglich hinzugefuegt oder daraus entfernt wurden. + const wurzelJetzt = backlogRoot(blaetter); + backlogSealStatus = + backlogTampered.length === 0 && wurzelJetzt === meta.root ? 'intakt' : 'gebrochen'; + if (wurzelJetzt !== meta.root && backlogTampered.length === 0) { + // Blattbestand selbst veraendert, ohne dass eine Zeile abweicht. + backlogSealStatus = 'gebrochen'; + } + } catch { + backlogSealStatus = 'gebrochen'; + } + } + tamperedEntries.push(...backlogTampered); + for (let i = 0; i < logs.length; i++) { const log = logs[i]; @@ -885,6 +1063,8 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ chainGaps, unexplainedGaps, unverifiableEntries, + backlogSealStatus, + backlogTampered, }; } diff --git a/docs/todo.md b/docs/todo.md index b0875246..97de628b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,42 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18) + - Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur + 7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine + `LOGIN_FAILED`-Zeile auf `success=1` setzen, das Label auf + „…hat sich angemeldet“ aendern und `errorMessage` leeren – **alles + Nicht-Hash-Felder, Hash unveraendert** → `/verify` meldete `valid=true`. + Ein Einbruchsversuch war unsichtbar in einen Erfolg umschreibbar. + - Rueckwirkend signieren geht nicht; ein Rehash waere die falsche Medizin. + Stattdessen **Bestandssiegel**: einmalig, nicht destruktiv, schreibt nichts + um. Je Altzeile ein Blattwert, die Wurzel darueber in einem HMAC-signierten + Marker. + - Umgesetzt exakt nach seinen vier Bedingungen: + 1. **Blaetter ueber den vollen Zeileninhalt** (alle Spalten inkl. `id` und + `hashVersion`), NICHT ueber den 7-Feld-V1-Hash – sonst lebte die Luecke + im Siegel weiter. + 2. **Wurzel signiert** – sie steht im Marker, der selbst Version 3 und + HMAC-signiert ist. Ohne `AUDIT_HMAC_KEY` wird das Siegeln abgelehnt. + 3. **Bereich fix auf `[1 … v3FromId-1]`** gebunden statt per Live-Abfrage + `hashVersion < 3`. Sein Umgehungsweg: ein Up-Flip der Grenzzeile + (V1→V2, schluessellos nachrechenbar) haette sie sonst aus der geprueften + Menge gedraengt – sie waere nur als harmlose Luecke erschienen. + 4. **Pruefung je id**: vorhanden, weiterhin Altbestand, Inhalt == Blatt. + Zusaetzlich Wurzelabgleich, damit auch ein veraenderter Blattbestand + auffliegt. + - Naht geschlossen: Zeilen ab `v3FromId` MUESSEN laut Versionsgrenze V3 + sein, es bleibt also keine ungesiegelte Zone dazwischen. + - Neuer Endpunkt `POST /audit-logs/seal-backlog` (`audit:admin`, + `{"confirm":"SEAL"}`). `/verify` meldet den Siegelzustand im Klartext – + auch wenn **kein** Siegel existiert („Aenderungen daran waeren nicht + erkennbar“). + - Verifiziert in einer separaten Wegwerf-Datenbank mit realistischem + Mischbestand (6×V1, 5×V2, 5×V3): ohne Siegel ist der Angriff unsichtbar + (`valid=true`) – mit Siegel wird er erkannt und die Zeile benannt (`[3]`); + sein Up-Flip der Grenzzeile wird ebenfalls erkannt (`[6]`). Wegwerf-DB + danach geloescht, Dev-Daten unberuehrt. `tsc` + `vite build` gruen. + - [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18) - **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert