diff --git a/backend/.env.example b/backend/.env.example index 0bb9d15c..8ae33db2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,3 +19,21 @@ ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm" # Server PORT=3001 NODE_ENV=development + +# ==================== AUDIT-ANKER ==================== +# Signiert Audit-Log-Eintraege per HMAC-SHA256 mit einem Schluessel, der NICHT +# in der Datenbank liegt. Damit reicht ein reiner DB-Schreibzugriff nicht mehr +# aus, um die Hash-Kette glaubwuerdig umzuschreiben (Pentest R166/R167). +# +# Erzeugen: openssl rand -hex 32 +# Fail-safe: Ohne Schluessel schreibt das Audit-Log weiter die unsignierte +# Version 2 - es faellt nichts aus, der Anker fehlt nur. +# ACHTUNG: Schluessel sichern. Geht er verloren, sind alle damit signierten +# Eintraege nicht mehr pruefbar (sie gelten dann als "nicht pruefbar", +# NICHT als manipuliert). +AUDIT_HMAC_KEY= + +# Nur waehrend eines Schluesselwechsels setzen: der vorherige Schluessel wird +# bei der Pruefung zusaetzlich akzeptiert, damit Altbestand ohne Rehash gueltig +# bleibt. Nach dem Wechsel wieder leeren. +AUDIT_HMAC_KEY_OLD= diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index 2a7cf184..83f69253 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -159,6 +159,11 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { : ' ohne dokumentierte Löschung') : ''; + const unverifiable = result.unverifiableEntries.length; + const keinSchluessel = unverifiable > 0 + ? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.` + : ''; + const message = tampered > 0 ? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '') : gaps > 0 @@ -178,7 +183,8 @@ 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: message + keinSchluessel, + unverifiableEntries: result.unverifiableEntries, }, }); } catch (error) { diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index 14794eb3..1fc39ed8 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -207,11 +207,49 @@ export interface AuditHashV2Input { previousHash?: string | null; } +/** + * Externer Anker (Pentest R166/R167, Design-Empfehlung). + * + * Bis Version 2 war die Kette selbsttragend: Wer die DB schreiben kann, kann + * jede Zeile aendern und saemtliche Folgehashes konsistent nachziehen – die + * Pruefung meldet dann sauber "gueltig". Version 3 signiert denselben Inhalt + * stattdessen per HMAC-SHA256 mit einem Schluessel, der NICHT in der Datenbank + * liegt (`AUDIT_HMAC_KEY`). Ohne diesen Schluessel laesst sich keine gueltige + * Signatur erzeugen – reiner DB-Schreibzugriff genuegt damit nicht mehr. + * + * Fail-safe: Ohne konfigurierten Schluessel wird weiterhin Version 2 + * geschrieben. Audit-Logging darf nie an fehlender Konfiguration scheitern. + * `AUDIT_HMAC_KEY_OLD` erlaubt einen Schluesselwechsel ohne Rehash – bei der + * Pruefung wird zusaetzlich gegen den alten Schluessel getestet. + */ +function auditHmacKey(): string | null { + const k = process.env.AUDIT_HMAC_KEY; + return k && k.trim().length > 0 ? k : null; +} + +function auditHmacKeyOld(): string | null { + const k = process.env.AUDIT_HMAC_KEY_OLD; + return k && k.trim().length > 0 ? k : null; +} + +function generateHashV3(data: AuditHashV2Input, key: string): string { + return crypto.createHmac('sha256', key) + .update(JSON.stringify(buildContentPayload(data, 3))) + .digest('hex'); +} + function generateHashV2(data: AuditHashV2Input): string { + return crypto.createHash('sha256') + .update(JSON.stringify(buildContentPayload(data, 2))) + .digest('hex'); +} + +/** Gemeinsamer Inhalts-Payload fuer Version 2 (SHA-256) und 3 (HMAC). */ +function buildContentPayload(data: AuditHashV2Input, version: 2 | 3): Record { const n = (v: T | null | undefined): T | null => (v === undefined ? null : v); // Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden. - const payload = { - v: 2, + return { + v: version, userId: n(data.userId), userEmail: data.userEmail, userRole: n(data.userRole), @@ -237,8 +275,6 @@ function generateHashV2(data: AuditHashV2Input): string { createdAt: data.createdAt.toISOString(), previousHash: data.previousHash || '', }; - - return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } /** @@ -352,8 +388,11 @@ export async function createAuditLog(data: CreateAuditLogData): Promise { const previousHash = lastRows[0]?.hash || null; const createdAt = new Date(); - // Neue Eintraege immer mit Version 2 (volle Feldabdeckung, R166-02). - const hash = generateHashV2({ + // Version 3 (HMAC-signiert) sobald ein Schluessel konfiguriert ist, + // sonst Version 2 – Audit-Logging darf an fehlender Konfiguration nicht + // scheitern (fail-safe, siehe generateHashV3). + const hmacKey = auditHmacKey(); + const inhalt = { userId: data.userId, userEmail: data.userEmail, userRole: data.userRole, @@ -378,7 +417,8 @@ export async function createAuditLog(data: CreateAuditLogData): Promise { durationMs: data.durationMs, createdAt, previousHash, - }); + }; + const hash = hmacKey ? generateHashV3(inhalt, hmacKey) : generateHashV2(inhalt); await tx.auditLog.create({ data: { @@ -407,7 +447,7 @@ export async function createAuditLog(data: CreateAuditLogData): Promise { createdAt, hash, previousHash, - hashVersion: 2, + hashVersion: hmacKey ? 3 : 2, }, }); }, { @@ -567,6 +607,12 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ * Loeschungs-Manifest erklaert ist. Nur diese sind erklaerungsbeduerftig. */ unexplainedGaps: number[]; + /** + * HMAC-signierte Zeilen, die mangels konfiguriertem `AUDIT_HMAC_KEY` nicht + * geprueft werden konnten. Bewusst KEIN Manipulationsverdacht – sonst waere + * eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log. + */ + unverifiableEntries: number[]; }> { const where: Prisma.AuditLogWhereInput = {}; @@ -629,11 +675,20 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ // neue Instanz parallel schreiben, koennen echte V1-Zeilen nach der Grenze // entstehen und werden dann angezeigt. Beim hier ueblichen Deploy // (pull + rebuild + restart einer Instanz) tritt das nicht auf. - const v2Cutover = await prisma.auditLog.aggregate({ - where: { hashVersion: { gte: 2 } }, - _min: { id: true }, - }); - const v2FromId = v2Cutover._min.id; + // Grenze pro Stufe: ab der ersten je mit Version >= v geschriebenen Zeile + // muss jede weitere Zeile mindestens Version v haben. Verallgemeinert auf + // Version 3 (HMAC-Anker), damit auch ein Ruecksprung 3→2 auffliegt. + const [c2, c3] = await Promise.all([ + prisma.auditLog.aggregate({ where: { hashVersion: { gte: 2 } }, _min: { id: true } }), + prisma.auditLog.aggregate({ where: { hashVersion: { gte: 3 } }, _min: { id: true } }), + ]); + const v2FromId = c2._min.id; + const v3FromId = c3._min.id; + const erwarteteVersion = (id: number): 1 | 2 | 3 => { + if (v3FromId !== null && id >= v3FromId) return 3; + if (v2FromId !== null && id >= v2FromId) return 2; + return 1; + }; // Dokumentierte Loeschungen einlesen: Luecken, die auf einen protokollierten // Retention-Cleanup zurueckgehen, sind erklaert – alle anderen nicht. Vorher @@ -662,25 +717,27 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ const tamperedEntries: number[] = []; const chainGaps: number[] = []; const unexplainedGaps: number[] = []; + const unverifiableEntries: number[] = []; for (let i = 0; i < logs.length; i++) { const log = logs[i]; // Erwartete Pruefstaerke aus der Kette, NICHT aus der Selbstauskunft. - const erwarteV2 = v2FromId !== null && log.id >= v2FromId; - if (erwarteV2 !== (log.hashVersion >= 2)) { + const erwartet = erwarteteVersion(log.id); + if (log.hashVersion !== erwartet) { // Deklarierte Version passt nicht zur Position in der Kette → // Downgrade-Versuch (oder manipulierte Version). tamperedEntries.push(log.id); continue; } - // Pruefverfahren richtet sich nach der Version, mit der geschrieben wurde. - // Version 2 deckt alle Inhaltsspalten ab; Version 1 nur 7 Felder und darf - // zusaetzlich die historische Serialisierung nutzen (generateHashLegacy). + // Pruefverfahren richtet sich nach der erwarteten Version: + // 3 = HMAC-signiert (Schluessel ausserhalb der DB) + // 2 = SHA-256 ueber alle Inhaltsspalten + // 1 = SHA-256 ueber 7 Felder, zusaetzlich historische Serialisierung let hashOk: boolean; - if (erwarteV2) { - hashOk = log.hash === generateHashV2({ + if (erwartet >= 2) { + const inhalt = { userId: log.userId, userEmail: log.userEmail, userRole: log.userRole, @@ -705,7 +762,23 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ durationMs: log.durationMs, createdAt: log.createdAt, previousHash: log.previousHash, - }); + }; + + if (erwartet === 3) { + // HMAC-signiert: ohne Schluessel ist die Zeile nicht pruefbar. Das als + // "manipuliert" zu melden waere ein Fehlalarm – daher eigener Topf. + const key = auditHmacKey(); + const keyOld = auditHmacKeyOld(); + if (!key && !keyOld) { + unverifiableEntries.push(log.id); + continue; + } + // keyOld deckt den Zeitraum vor einem Schluesselwechsel ab. + hashOk = (!!key && log.hash === generateHashV3(inhalt, key)) + || (!!keyOld && log.hash === generateHashV3(inhalt, keyOld)); + } else { + hashOk = log.hash === generateHashV2(inhalt); + } } else { const v1 = { userEmail: log.userEmail, @@ -731,6 +804,19 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ chainGaps.push(log.id); if (!gapErklaert(previousLog.id, log.id)) { unexplainedGaps.push(log.id); + // Ist die AKTUELLE Zeile HMAC-signiert, laesst sich ihr + // `previousHash` ohne Schluessel nicht nachziehen. Eine unerklaerte + // Luecke davor bedeutet dann zwingend: der Vorgaenger wurde + // veraendert oder entfernt. Das ist kein struktureller Zufall, + // sondern ein Befund. + // + // Dieser Pfad schliesst zugleich die Restluecke der Versionsgrenze: + // Wird die FRUEHESTE V3-Zeile herabgestuft, wandert `MIN(id)` mit und + // die Grenze allein wuerde den Downgrade durchwinken – der Nachfolger + // erzeugt aber eine nicht faelschbare Luecke. + if (erwartet === 3) { + tamperedEntries.push(log.id); + } } } } @@ -745,6 +831,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ tamperedEntries, chainGaps, unexplainedGaps, + unverifiableEntries, }; } @@ -782,17 +869,19 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> { }, }); + const rehashKey = auditHmacKey(); let previousHash: string | null = null; let count = 0; for (const log of logs) { - // Rehash schreibt immer Version 2 (volle Feldabdeckung) – ein Rueckfall - // auf Version 1 wuerde die Abdeckung nachtraeglich wieder verkleinern. - const hash = generateHashV2({ ...log, previousHash }); + // Rehash schreibt die hoechste verfuegbare Stufe – ein Rueckfall auf eine + // schwaechere Version wuerde die Abdeckung nachtraeglich verkleinern. + const inhalt: AuditHashV2Input = { ...log, previousHash }; + const hash: string = rehashKey ? generateHashV3(inhalt, rehashKey) : generateHashV2(inhalt); await prisma.auditLog.update({ where: { id: log.id }, - data: { hash, previousHash, hashVersion: 2 }, + data: { hash, previousHash, hashVersion: rehashKey ? 3 : 2 }, }); previousHash = hash; diff --git a/docs/todo.md b/docs/todo.md index 04db880d..a0009c5c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,39 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18) + - Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die + Kette selbsttragend – wer die DB schreiben kann, konnte jede Zeile aendern + und alle Folgehashes konsistent nachziehen, die Pruefung meldete „gueltig“. + - Version 3 signiert denselben Inhalt per **HMAC-SHA256** mit `AUDIT_HMAC_KEY` + – einem Schluessel, der NICHT in der Datenbank liegt. Ohne ihn laesst sich + keine gueltige Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr. + - **Fail-safe:** Ohne Schluessel schreibt das Audit-Log weiter Version 2, es + faellt nichts aus. Signierte Zeilen gelten dann als **nicht pruefbar** + (eigener Topf `unverifiableEntries`) und ausdruecklich NICHT als + manipuliert – eine fehlende Konfiguration darf kein Fehlalarm ueber das + gesamte Log sein. + - **Schluesselwechsel ohne Rehash:** `AUDIT_HMAC_KEY_OLD` wird bei der + Pruefung zusaetzlich akzeptiert. Verifiziert: mit KEY_OLD 0 Befunde, ohne + KEY_OLD werden die alt signierten Zeilen erwartungsgemaess auffaellig. + - **Restluecke der Versionsgrenze geschlossen (selbst gefunden):** Wird die + FRUEHESTE Zeile einer Stufe herabgestuft, wandert `MIN(id)` mit – die + Grenze allein haette den Downgrade durchgewunken (mein erster Testlauf fiel + genau darauf durch). Der Nachfolger ist jedoch HMAC-signiert, sein + `previousHash` ist ohne Schluessel nicht faelschbar. Eine unerklaerte Luecke + vor einer signierten Zeile gilt deshalb jetzt als Befund, nicht als + struktureller Zufall. + - Verifiziert: Inhalt geaendert + Hash beliebig → erkannt; Downgrade 3→2 mit + gueltigem V2-Hash auf der **fruehesten** V3-Zeile → erkannt; dasselbe auf + der **letzten** V3-Zeile (kein Nachfolger) → erkannt; ohne Schluessel + 0 manipuliert / 2 nicht pruefbar; 40 parallele Schreiber → 40/40, 0 Forks, + alle V3. `tsc` + `vite build` gruen. + - **Betrieb:** `AUDIT_HMAC_KEY` in `.env.example` dokumentiert + (`openssl rand -hex 32`). Schluessel sichern – geht er verloren, sind die + damit signierten Eintraege dauerhaft nicht mehr pruefbar (aber nicht als + manipuliert gemeldet). Der Dev-Schluessel liegt nur lokal in `.env` + (gitignored) und ist NICHT der Prod-Schluessel. + - [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18) - Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02 selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1bdc87b8..226cb468 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1752,7 +1752,7 @@ export const auditLogApi = { return res.data; }, verifyIntegrity: async () => { - const res = await api.post>('/audit-logs/verify'); + const res = await api.post>('/audit-logs/verify'); return res.data; }, rehash: async () => {