feat(audit): hash-chain tamper evidence + signed checkpoints (Phase 3)
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)
This commit is contained in:
@@ -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<CheckpointSummary>(
|
||||
'/api/audit/checkpoint/latest',
|
||||
{ default: () => ({ at: null, headSeq: null, headHash: null }), server: false },
|
||||
)
|
||||
const verifyReport = ref<VerifyReport | null>(null)
|
||||
const verifying = ref(false)
|
||||
|
||||
async function runVerify() {
|
||||
verifying.value = true
|
||||
try {
|
||||
verifyReport.value = await $fetch<VerifyReport>('/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')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,10 +213,56 @@ function shortIp(ip?: string) {
|
||||
<Mono v-else dim>// reached the start of the log</Mono>
|
||||
</div>
|
||||
|
||||
<!-- Tamper-evidence panel — Phase 3 -->
|
||||
<Card :pad="0" class="verify-card">
|
||||
<div class="verify-head">
|
||||
<div>
|
||||
<Eyebrow>Tamper evidence</Eyebrow>
|
||||
<div class="cap">
|
||||
Hash chain · {{ latestCp?.headSeq != null ? `signed through seq ${latestCp.headSeq}` : 'no checkpoints yet' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="verify-actions">
|
||||
<UiButton variant="secondary" :disabled="verifying" @click="forceCheckpoint">
|
||||
Force checkpoint
|
||||
</UiButton>
|
||||
<UiButton variant="primary" :disabled="verifying" @click="runVerify">
|
||||
{{ verifying ? 'Verifying…' : 'Verify chain' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verify-meta">
|
||||
<div class="kv"><Eyebrow>Last signed checkpoint</Eyebrow><Mono>{{ fmtRelative(latestCp?.at ?? null) }}</Mono></div>
|
||||
<div v-if="latestCp?.headHash" class="kv"><Eyebrow>Head hash</Eyebrow><Mono dim>{{ latestCp.headHash.slice(0, 16) }}…</Mono></div>
|
||||
<div v-if="latestCp?.reason" class="kv"><Eyebrow>Reason</Eyebrow><Mono dim>{{ latestCp.reason }}</Mono></div>
|
||||
</div>
|
||||
|
||||
<!-- Verify result -->
|
||||
<div v-if="verifyReport" class="verify-result" :data-ok="verifyReport.ok">
|
||||
<div v-if="verifyReport.ok" class="result-line">
|
||||
<Badge tone="ok" dot>verified</Badge>
|
||||
<Mono>{{ verifyReport.totalEventsVerified }} event(s) · {{ verifyReport.checkpointsChecked }} checkpoint(s) · last seq {{ verifyReport.latestVerifiedSeq ?? '—' }}</Mono>
|
||||
</div>
|
||||
<div v-else class="result-line">
|
||||
<Badge tone="bad" dot>BROKEN</Badge>
|
||||
<Mono v-if="verifyReport.break?.kind === 'event-hash-mismatch'">
|
||||
event hash mismatch at seq {{ verifyReport.break.seq }} · stored {{ verifyReport.break.actual.slice(0, 16) }}… expected {{ verifyReport.break.expected.slice(0, 16) }}…
|
||||
</Mono>
|
||||
<Mono v-else-if="verifyReport.break?.kind === 'event-prev-hash-mismatch'">
|
||||
chain link broken at seq {{ verifyReport.break.seq }} · prevHash mismatch
|
||||
</Mono>
|
||||
<Mono v-else-if="verifyReport.break?.kind === 'checkpoint-signature-mismatch'">
|
||||
checkpoint signature mismatch at head seq {{ verifyReport.break.headSeq }}
|
||||
</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Mono dim class="note">
|
||||
// sourced from /audit on platform-api · append-only · hash-chain tamper
|
||||
evidence + external system ingest (Authentik / OCIS / Stalwart) are
|
||||
planned follow-ups (see docs/NEXT-STEPS.md)
|
||||
// sourced from /audit on platform-api · append-only · sha256 hash-chain
|
||||
with HMAC-signed checkpoints every 100 events or 5 minutes · retention
|
||||
+ cold-storage archival to Hetzner Object Storage is Phase 4
|
||||
</Mono>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,4 +343,29 @@ td.actor { display: flex; align-items: center; gap: 10px; }
|
||||
.empty { padding: 40px 20px; text-align: center; }
|
||||
.footer { display: flex; justify-content: center; padding: 4px 0; }
|
||||
.note { display: block; padding: 4px 4px 0 4px; }
|
||||
|
||||
/* Tamper-evidence panel */
|
||||
.verify-card { margin-top: 8px; }
|
||||
.verify-head {
|
||||
padding: 14px 18px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.verify-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
|
||||
.verify-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.verify-meta {
|
||||
padding: 12px 18px;
|
||||
display: flex; gap: 24px; flex-wrap: wrap;
|
||||
}
|
||||
.verify-meta .kv { display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.verify-result {
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.verify-result[data-ok="true"] { background: rgba(31, 138, 91, 0.05); }
|
||||
.verify-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
|
||||
.result-line { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user