Skip to content

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 infocritical.
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/>&lt;baseline.db&gt;.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 append records the highest committed (seq, entry_hash) to a small file <baseline.db>.audit-head outside the database. It advances monotonically and is written atomically (temp file + rename). verify_chain requires 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_anchor table) — advanced monotonically inside the same transaction as each row, in a separate table that a DELETE FROM audit_log tail-delete does not touch. If the high-water sits above the surviving max seq, the tail was selectively deleted — a rollback the forward walk cannot see and that an attacker's own re-append would 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