import { createHash } from 'node:crypto'; import type { PoolConnection } from 'mysql2/promise'; import { pool, one, query } from './db.js'; const SENSITIVE = /pass|secret|token|hash|code|key|totp/i; /** Maskiert Geheimnisse rekursiv, bevor Zustände in das Audit-Protokoll gelangen. */ export function mask(v: unknown): unknown { if (Array.isArray(v)) return v.map(mask); if (v && typeof v === 'object') { return Object.fromEntries(Object.entries(v as Record).map(([k, val]) => [k, SENSITIVE.test(k) ? '***' : mask(val)])); } return v; } export interface AuditInput { actorType: 'user' | 'system' | 'anonymous'; actorId?: string | null; orgId?: string | null; action: string; resourceType?: string; resourceId?: string; result?: 'success' | 'denied' | 'failure'; errorClass?: string; connector?: string; correlationId?: string; ip?: string; before?: unknown; after?: unknown; } const canon = (o: unknown) => JSON.stringify(o); /** Hängt ein Ereignis an die Hash-Kette an. Serialisiert über Zeilensperre auf dem letzten Eintrag. */ export async function audit(e: AuditInput, conn?: PoolConnection): Promise { const own = !conn; const c = conn ?? (await pool.getConnection()); try { if (own) await c.beginTransaction(); await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']); const last = await one<{ hash: string } & import('mysql2').RowDataPacket>('SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1', [], c); const prev = last?.hash ?? '0'.repeat(64); const ts = new Date(); const body = { ts: ts.toISOString(), actor_type: e.actorType, actor_id: e.actorId ?? null, org_id: e.orgId ?? null, action: e.action, resource_type: e.resourceType ?? null, resource_id: e.resourceId ?? null, result: e.result ?? 'success', error_class: e.errorClass ?? null, correlation_id: e.correlationId ?? null, before: e.before === undefined ? null : mask(e.before), after: e.after === undefined ? null : mask(e.after), }; const hash = createHash('sha256').update(prev + canon(body)).digest('hex'); await c.execute( `INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, e.connector ?? null, body.correlation_id, e.ip ?? null, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash], ); if (own) await c.commit(); } catch (err) { if (own) await c.rollback(); throw err; } finally { await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined); if (own) c.release(); } } /** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */ export async function verifyAuditChain(q: (sql: string) => Promise = (sql) => query(sql)): Promise<{ checked: number; brokenAt: number | null }> { const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, before_json, after_json, prev_hash, hash FROM audit_events ORDER BY id'); let prev = '0'.repeat(64); for (const r of rows) { const body = { ts: (r.ts as Date).toISOString(), actor_type: r.actor_type, actor_id: r.actor_id, org_id: r.org_id, action: r.action, resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, correlation_id: r.correlation_id, before: r.before_json ?? null, after: r.after_json ?? null, }; const expect = createHash('sha256').update(prev + canon(body)).digest('hex'); if (r.prev_hash !== prev || r.hash !== expect) return { checked: rows.length, brokenAt: r.id as number }; prev = r.hash as string; } return { checked: rows.length, brokenAt: null }; }