The audit log¶
Every trust-changing action on the verifier — and every finding — is recorded in
a self-contained, hash-chained, append-only log that lives entirely inside
baseline.db, with an external anchor outside the database that catches
tail-truncation and rollback.
The audit log is how Etminan answers "who changed trust, when, and has anything been quietly removed since?" — without a second "witness box" whose integrity you would then also have to trust.
What is recorded¶
Each enroll, approve, reject, exclude create/revoke, assign-profile,
rotate-tls, rotate-ak, identity change, and every system-detected finding
from run appends one row. Every operator action reaches the log the same way —
through the etminan-verifierd daemon
as an op command. A row carries:
| Column | Meaning |
|---|---|
seq |
Monotonic sequence number, contiguous from 1. |
recorded_at |
RFC 3339 timestamp. |
actor |
The operator, as operator:<label> — or run, for system findings. |
action |
The action name, e.g. enrol, approve, tls_cert_rotated, security_finding. |
resource |
The host or object acted on. |
severity |
info … critical. |
detail |
Canonical JSON of the action's specifics. For operator-attended actions this includes the operator's own Ed25519 signature over the underlying action. |
prev_hash |
The previous row's entry_hash (genesis is 64 hex zeros). |
entry_hash |
SHA-256 over this row's canonical fields plus prev_hash. |
Two properties combine here. The folded-in daemon signature makes a row's
content authentic — it attests who authorized the action. The hash chain proves
nothing was later removed, edited, or reordered. System findings from run have
no authorizing operator and are chained the same way, unsigned at the content
level but still covered by the tamper-evident sequence.
Daemon-mediated actions and their access decisions
When an operator acts through the daemon, the daemon — not the operator —
signs the row with its one Ed25519 key, and the actor is recorded as
operator:<label> (the label of the identity the caller's kernel UID maps
to). Crucially, the daemon writes every RBAC decision to this same
hash-chained, externally-anchored log — the allow and the deny: an
out-of-scope host, an unmapped UID, or an unauthenticated session produces an
audited refusal, not a silent drop. Because the model is default-deny /
fail-closed, the denials are part of the evidence, not just the approvals.
The hash chain¶
Because each row's entry_hash covers the previous row's entry_hash, editing,
deleting, or reordering any row breaks every hash after it.
flowchart LR
G["GENESIS<br/>0000…0000"] --> R1
subgraph R1["seq 1 · enrol web-01"]
H1["entry_hash₁ = SHA256(fields₁ ‖ prev_hash=GENESIS)"]
end
subgraph R2["seq 2 · approve web-01"]
H2["entry_hash₂ = SHA256(fields₂ ‖ prev_hash=entry_hash₁)"]
end
subgraph R3["seq 3 · finding db-02"]
H3["entry_hash₃ = SHA256(fields₃ ‖ prev_hash=entry_hash₂)"]
end
R1 -->|prev_hash| R2 -->|prev_hash| R3
R3 -.->|"(seq, entry_hash) copied out"| A["External anchor<br/><baseline.db>.audit-head"]
verify_chain walks every row in seq order, recomputes each entry_hash, and
confirms it matches both the stored value and the next row's prev_hash. It
advances from the stored hash, not the recomputed one, so a single content-only
edit surfaces as exactly one error at that row rather than cascading a false
chain-break onto every later row. An empty log is valid — an idle verifier that
has never had a finding or approval is a normal state.
The external anchor and the in-DB high-water¶
A forward-only hash chain has one blind spot: it cannot detect deletion of its
newest rows (there is no later row whose prev_hash would break), nor a
whole-database rollback to an older state — verify_chain walking from genesis
has no notion of where the chain is supposed to end. Etminan closes this with
two independent "where should the end be?" witnesses:
- External sibling anchor — every
appendrecords the highest committed(seq, entry_hash)to a small file<baseline.db>.audit-headoutside the database. It advances monotonically and is written atomically (temp file + rename).verify_chainrequires the table to still contain the exact anchored row. The table being ahead of the anchor is benign (a crash between commit and the anchor write); the table being behind it means rows were truncated or the DB was rolled back. - In-DB high-water (
audit_anchortable) — advanced monotonically inside the same transaction as each row, in a separate table that aDELETE FROM audit_logtail-delete does not touch. If the high-water sits above the surviving maxseq, the tail was selectively deleted — a rollback the forward walk cannot see and that an attacker's own re-appendwould otherwise hide by re-creating the sibling anchor at the new, lower head.
Together these catch tail truncation, accidental backup-restore, and WAL rollback.
This is a detective control, not an unforgeable one
An attacker with raw write access to baseline.db could delete a row,
recompute every entry_hash after it, and rewrite both anchors into a
fully self-consistent replacement. That matches Etminan's stated threat
model: operator DB + CLI access on the verifier host is the trust boundary the
project accepts — a fully compromised verifier can suppress alarms regardless.
The audit log remains a strong detective control for a partially compromised
or accidentally corrupted store: a bug, a bad migration, selective tampering,
an unnoticed rollback. Harden the verifier host itself
(Hardening) to raise this bar.
Mailing the head off the host¶
The chain detects an edit. What it cannot detect is somebody rewriting the entries and the chain together, which root on the verifier can do in an afternoon — and because the daemon's signing key lives on that same host, the rewritten chain gets re-signed and verifies perfectly. A self-contained chain proves internal consistency, never authenticity.
What closes that is a copy of the head in somebody else's hands. Set:
in /etc/etminan-verifier/verifier.env and restart the verifier. The hourly
run then mails the head once a calendar day, signed with the daemon key,
and records what it sent. To send one immediately — before touching a host you
are about to investigate, say:
That is admin-only. It is the one op verb whose effect leaves the building.
Why a mail, and not something grander¶
An anchor has to be outside your control, dated by somebody else, and hard to retract. A mail in a recipient's mailbox, on their server, with their timestamps, is all three. A timestamping authority or a public ledger adds a third party to supply a property you already have.
The mail explains itself — it carries the head, the entry count, and what to check — and the signature is written into the body rather than attached, so it can be verified by hand out of a stranger's mailbox years later without reassembling MIME parts.
Read it carefully once, because it is not what it looks like: the log will have
grown since the mail was sent, so the current head will differ and is meant
to. The question the mail answers is whether entry seq is still entry seq,
carrying that hash. If it is not, the history was rewritten after the mail left
— and the mail is the evidence.
It refuses your own domain, and that is not a bug
If ETMINAN_ANCHOR_TO sits on the same mail domain the verifier sends
from, the anchor is refused outright rather than warned about. The mail's
own body states that it dates the head outside your control; sending it to
yourself makes that sentence false, and whoever could rewrite the history
on this host can reach that mailbox too. A false assurance in the one place
someone looks for reassurance is worse than a gap they can see. Point it at
an auditor, an accountant, or anyone whose mail server you do not
administer.
Two further honest limits:
- A chain that does not verify is never anchored. Publishing a head that fails its own check would make the break harder to see, because the mail would appear to endorse it. You get an error instead.
- Air-gapped deployments get no anchor this way. Mail relays out of the
enclave, so air-gap mode suppresses it. Use the
file:witness (ETMINAN_AUDIT_WITNESS) onto an independently-administered append-only mount instead.
This is a different control from exporting the trail. The anchor is small and continuous and answers "was history rewritten?". The export is complete and periodic and answers "is this trail intact?". Neither substitutes for the other.
Verifying the log¶
Chain verification is not a manual chore you must remember — it runs on every
cycle. etminan-verifier run re-verifies the whole chain each hour and fires an
alarm on any break. To check on demand:
# Re-check every signature AND the full audit-log hash chain in one pass:
etminan-verifier baseline verify-signatures
That call re-checks the external anchor and the in-DB high-water as well.
How long it is kept¶
Nothing in the audit log is ever deleted, rotated or aged out. There is no retention setting, and that is a design decision rather than an omission: the log is a hash chain, and removing an entry breaks it. A chain with a gap is indistinguishable from a chain somebody tampered with, which would make the whole mechanism worthless.
So it grows for as long as the verifier runs, bounded by what it records — trust-changing actions, not attestation traffic. A busy deployment writes a few thousand entries a year, which is single-digit megabytes.
Retention in the sense a customer's questionnaire means it — how long do you keep this, and can we have a copy — is answered by the evidence bundle above: export on whatever schedule your own obligations require, keep the bundles wherever you keep evidence, and each one stays independently verifiable for as long as you hold it, including after the verifier that produced it has been decommissioned.
Backup interaction¶
The external anchor makes backup ordering matter: if a restored baseline.db is
behind its .audit-head, verify_chain reports it as tampering. Always back up
a WAL-safe snapshot whose anchor is derived from the same generation — never
the live database and a separately-timed anchor. This is exactly what the
snapshot pre-job in Backup & restore does.
See also¶
- Hardening the deployment — protecting the host the log lives on.
- Backup & restore — capturing the log and its anchor consistently.
- Operator-key authorization — the keys whose signatures are folded into rows.