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 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/>&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.

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:

ETMINAN_ANCHOR_TO=audit@your-auditor.example
ETMINAN_NOTIFY_FROM=verifier@your-company.example

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:

etminan-verifier op anchor

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