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 trust-changing action goes through the
etminan-verifierd daemon
as an op command; there is no other
path. A row carries:
| Column | Meaning |
|---|---|
seq |
Monotonic sequence number, contiguous from 1. |
recorded_at |
RFC 3339 timestamp. |
actor |
The operator — operator:<label> for a daemon-mediated action (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 operator 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 operator key to embed 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.
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 op verify-signatures
Both run and op verify-signatures call the same verify_chain, which
internally re-checks the external anchor and the in-DB high-water as well.
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.
- RBAC and operator identities — the UID-mapped identities the daemon signs actions on behalf of.