Tamper-evident audit export¶
The self-hosted server has hash-chained every audit entry into the previous one since the beginning. Two things were missing, and both of them are the part an auditor actually needs.
The auditor could not run the check. Verification lived inside the process that wrote the log, reading the database that holds it. That is the one arrangement that proves nothing: a party who can rewrite the entries can rewrite the verifier too. Evidence has to be checkable away from the thing it is evidence about.
And False is not an answer. A tamper-evident log that reports "invalid"
without saying which entry broke sends a responder to read ten thousand rows by
hand, and gives an auditor nothing to put in a finding.
apiverity audit export --db server.db --org-id 1 -o audit-2026-09.json
apiverity audit verify audit-2026-09.json --against audit-2026-08.json
verify reads the file and nothing else — no database, no server, no network.
What the document contains¶
Every entry, oldest first, with no page limit. GET /v1/audit caps at a hundred
because it renders a screen; an export that stopped there would be a shorter
history presented as the whole one.
Alongside them, the algorithm in words:
| Field | Value |
|---|---|
hash |
sha256 |
basis |
{prev_hash}\|{ts}\|{actor}\|{action}\|{target}\|{payload_json} |
encoding |
utf-8 |
digest |
lowercase hex |
first_prev_hash |
the empty string |
That block is not decoration. It is what lets someone reimplement the check in
twenty lines of anything, which is the difference between evidence and a claim.
The entry fields carried are fixed (id, ts, actor, action, target,
payload_json, prev_hash, entry_hash) rather than "whatever columns the
table has", so a column added later cannot change the sealed bytes and make
every stored export fail to verify.
A broken chain is exported too, with chain.valid: false and the id of the
first entry that failed. An export that refused to write a broken chain would
let the break be hidden by exporting.
What it proves, and what it does not¶
Stated here, and stated again in the limits array of every export document,
because an evidence artifact that overstates what it proves is worse than no
artifact.
| Attack | Detected by | Notes |
|---|---|---|
| An entry was edited | the chain, alone | entry_hash stops matching that entry's contents. Reported as it was edited, with the id. |
| An entry was inserted, removed or reordered in the middle | the chain, alone | prev_hash stops matching the preceding entry. Reported separately, because a broken link and an edited row mean different things. |
| Entries were deleted from the end | only --against an earlier export |
A truncated chain verifies perfectly: the tail is where the chain ends, so nothing points past it. This is the failure that matters most in practice — somebody removes the last four entries, which are the interesting ones. |
| The whole history was rebuilt | only the seal, or --against |
The algorithm is public, so anyone who can rewrite every row can recompute every hash. What they cannot recompute is an HMAC whose key was never in the database. |
The second and third rows are the reason exports are worth keeping rather
than regenerating on demand. Each export records its last_entry_hash, and the
next export must still contain every earlier entry, at the same index, with the
same hash. --against is that comparison, and it reports continuous,
truncated, rewritten or different org.
The seal¶
export APIVERITY_AUDIT_KEY='...'
apiverity audit export --db server.db --org-id 1 -o audit.json \
--hmac-key-env APIVERITY_AUDIT_KEY
The flag takes the name of an environment variable, never the value. A key passed on the command line is in the shell history, in the CI log that echoes the command, and in the process table for every other user on the machine. An unset variable is a usage error rather than a silently unsealed export, because handing somebody a document they believe is sealed is worse than refusing.
The seal is HMAC-SHA256 over the document with its seal member removed,
serialised with sorted keys and no whitespace between tokens — so a verifier in
another language can parse, re-serialise and reach the same digest.
GET /v1/audit/export deliberately does not offer sealing. It would mean
the server holding the key, and a key held by the system under audit seals
nothing.
Verification reports the seal in four states, because "the seal was fine" and "nobody looked at the seal" are the two things an auditor must not confuse:
validinvalid— "the document was altered, or the key differs", both readings named, because both are realpresent, not checked (no key supplied)expected but absent— a key was supplied and the document carries no seal
Exit codes¶
audit export and audit verify follow the exit-code
contract: 0 when the chain holds, 1 when it does not. Exit
0 on a broken chain would report that the export succeeded while saying
nothing about the log, which is the wrong half.
Where this fits¶
The mapping from findings to SOC 2, ISO 42001, DORA and the EU AI Act is in compliance mapping; packaging a dated set of artifacts is in evidence packs. This document is about the one artifact that has to survive being disbelieved.