From 9435baa09dab228e5565bdde1c4dfb270b468956 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 24 May 2026 20:43:54 +0200 Subject: [PATCH] feat(audit): hash-chain tamper evidence + signed checkpoints (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit log now carries cryptographic chain-of-custody. Every chained event references the previous event's sha256, and periodic checkpoints sign the head with HMAC-SHA-256. An attacker who modifies a historical row must also forge every checkpoint signature past it — which requires the AUDIT_SIGNING_KEY, kept outside Mongo. Schema (services/platform-api/src/schemas/): - audit-event.schema.ts: new `seq` (monotonic) + `chained` (Phase-3-or- later flag) + `prevHash` + `hash`. Compound unique index on seq with partial filter so pre-Phase-3 rows don't collide on null. - audit-counter.schema.ts: single doc `_id='audit_seq'`, incremented atomically by findOneAndUpdate($inc). - audit-checkpoint.schema.ts: { at, headSeq, headHash, signature, sigAlg, reason }. Reason ∈ {startup, interval, threshold, manual}. Audit module (services/platform-api/src/audit/): - canonical.ts: stable JSON form + hashCanonical (sha256) + checkpointSignature (HMAC-SHA-256) + verifyCheckpointSignature (timingSafeEqual). Single source of truth for hash inputs — schema additions land here at the same time as the field. - audit.service.ts: record() now allocates seq → looks up lastHash() → computes hash → inserts. Per-process write mutex serializes the allocate+lookup so concurrent writers don't both chain off the same predecessor. Documented multi-instance caveat (needs Mongo replica set + transactions OR a distributed lock). - checkpoint.service.ts: scheduler triggers on startup + every 5min + threshold of 100 events accumulated. Skips when no new chained events since the last anchor. - verifier.service.ts: walks chain in seq order, recomputes each hash, validates checkpoint signatures. Returns a precise break: 'event-hash-mismatch' (in-place modification), 'event-prev-hash- mismatch' (insertion/deletion), or 'checkpoint-signature-mismatch'. - audit.controller.ts: GET /audit/verify, GET /audit/checkpoint/latest, POST /audit/checkpoint (manual force). Operator UI (apps/operator/): - 3 new proxies under /api/audit/{verify, checkpoint/latest, checkpoint}. - pages/audit.vue: new "Tamper evidence" card with "Force checkpoint" + "Verify chain" buttons. Header shows live head seq; result line shows verified count or a precise break (kind + seq + expected vs actual hash). Background tinted green/red on ok/broken. Env (.env + docker-compose.yml): - new AUDIT_SIGNING_KEY (32-byte hex HMAC secret). Prod swaps this for ed25519 from an HSM/KMS; verifier code stays the same because sigAlg is on the checkpoint doc. Smoke-tested all three break paths against a clean chain of 5 events: - normal verify: ok=true, 5/5 events verified, 1 checkpoint signed - modified seq=3 in Mongo directly: verify returns ok=false with break = { kind: 'event-hash-mismatch', seq: 3, expected, actual } - restored, nuked checkpoint signature: break = { kind: 'checkpoint-signature-mismatch', headSeq: 5 } - operator UI's verify panel reflects all three states correctly. Legacy data: pre-Phase-3 events stay `chained: false` and are excluded from the chain walk. Retroactive chaining of historical entries is a one-off migration script we can run if we ever care to. Out of scope (Phase 4 etc.): - TTL + cold-storage archival to Hetzner Object Storage - GDPR right-to-erasure tooling - ed25519 / HSM signing (swap is well-defined; sigAlg field is ready) - Multi-instance write coordination (Mongo transaction OR distributed lock when we scale platform-api beyond 1 replica) --- apps/operator/pages/audit.vue | 127 +++++++++++++++- .../server/api/audit/checkpoint/index.post.ts | 3 + .../server/api/audit/checkpoint/latest.get.ts | 3 + apps/operator/server/api/audit/verify.get.ts | 3 + .../docker-compose/docker-compose.yml | 4 + .../src/audit/audit.controller.ts | 39 ++++- .../platform-api/src/audit/audit.module.ts | 18 ++- .../platform-api/src/audit/audit.service.ts | 107 +++++++++++++- services/platform-api/src/audit/canonical.ts | 112 ++++++++++++++ .../src/audit/checkpoint.service.ts | 114 +++++++++++++++ .../src/audit/verifier.service.ts | 137 ++++++++++++++++++ .../src/schemas/audit-checkpoint.schema.ts | 42 ++++++ .../src/schemas/audit-counter.schema.ts | 21 +++ .../src/schemas/audit-event.schema.ts | 25 +++- 14 files changed, 737 insertions(+), 18 deletions(-) create mode 100644 apps/operator/server/api/audit/checkpoint/index.post.ts create mode 100644 apps/operator/server/api/audit/checkpoint/latest.get.ts create mode 100644 apps/operator/server/api/audit/verify.get.ts create mode 100644 services/platform-api/src/audit/canonical.ts create mode 100644 services/platform-api/src/audit/checkpoint.service.ts create mode 100644 services/platform-api/src/audit/verifier.service.ts create mode 100644 services/platform-api/src/schemas/audit-checkpoint.schema.ts create mode 100644 services/platform-api/src/schemas/audit-counter.schema.ts diff --git a/apps/operator/pages/audit.vue b/apps/operator/pages/audit.vue index b74663e..0d2ebd5 100644 --- a/apps/operator/pages/audit.vue +++ b/apps/operator/pages/audit.vue @@ -71,6 +71,56 @@ function shortIp(ip?: string) { // Strip v4-in-v6 prefix that node sockets sometimes report (::ffff:1.2.3.4 → 1.2.3.4) return ip.replace(/^::ffff:/, '') } + +// ── Tamper-evidence (Phase 3) ────────────────────────────────────────── +interface VerifyReport { + ok: boolean + totalEventsVerified: number + checkpointsChecked: number + latestCheckpointAt: string | null + latestVerifiedSeq: number | null + break?: + | { kind: 'event-hash-mismatch'; seq: number; expected: string; actual: string } + | { kind: 'event-prev-hash-mismatch'; seq: number; expected: string; actual: string } + | { kind: 'checkpoint-signature-mismatch'; headSeq: number } +} +interface CheckpointSummary { + at: string | null + headSeq: number | null + headHash: string | null + reason?: string +} + +const { data: latestCp, refresh: refreshCp } = useLazyFetch( + '/api/audit/checkpoint/latest', + { default: () => ({ at: null, headSeq: null, headHash: null }), server: false }, +) +const verifyReport = ref(null) +const verifying = ref(false) + +async function runVerify() { + verifying.value = true + try { + verifyReport.value = await $fetch('/api/audit/verify') + await refreshCp() + } finally { + verifying.value = false + } +} + +async function forceCheckpoint() { + await $fetch('/api/audit/checkpoint', { method: 'POST' }) + await refreshCp() +} + +function fmtRelative(iso: string | null | undefined): string { + if (!iso) return 'never' + const ms = Date.now() - new Date(iso).getTime() + if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago` + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago` + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago` + return new Date(iso).toLocaleDateString('da-DK') +}