Installing both binaries, configuring a monitored host and a verifier device, setting up mTLS and enrollment, every CLI command either binary ships with an explanation and a runnable example, day-to-day review, extending the plugin points, and what to do when something looks wrong. For the trust model and design rationale behind any of this, see Architecture & design.
The verifier's entire trust value comes from being administratively separate from the hosts it checks — never share a machine between the two roles.
A relay, not a trust anchor. Needs a TPM 2.0 device (physical or vTPM) and the kernel's IMA subsystem enabled.
No TPM needed. Polls every enrolled agent on a schedule and is the only place an alarm ever fires from.
The postinst step creates the etminan-agent (group
tss) and etminan-verifier system
users, /var/lib/etminan-{agent,verifier}, and
/etc/etminan-{agent,verifier}/ — but enables nothing. Pick the
role(s) this host plays:
etminan-agent-ima-policy.service is a one-shot unit that writes
the IMA measurement policy once, early at boot, before the agent daemon starts — zero
manual reboot/policy steps needed. It's idempotent: if a policy is already active this
boot, that's treated as success, not an error. etminan-verifier.timer
runs etminan-verifier run hourly (OnBootSec=10min,
OnUnitActiveSec=1h) — one pass over every enrolled host, firing
the alarm path on anything wrong.
Copy deploy/agent.env.example to
/etc/etminan-agent/agent.env and edit it. An agent started
with no watched-path setting watches nothing and prints a loud startup warning —
deliberate, so a host never silently inherits another project's paths.
| Variable | Default | Purpose |
|---|---|---|
| ETMINAN_AGENT_LISTEN | 0.0.0.0:7620 | Listen address for quote requests from the verifier. |
| ETMINAN_TLS_DIR | /var/lib/etminan-agent/tls | Where this agent's own mTLS keypair lives (generated once via keygen-tls). |
| ETMINAN_VERIFIER_CERT_FINGERPRINT | unset | Pins which verifier this agent accepts connections from. Unset accepts any client certificate and warns loudly at every startup. |
| ETMINAN_WATCHED_PATHS | unset | Direct, comma-separated path list. Highest precedence of the three ways to set watched paths. |
| ETMINAN_PROFILE | unset | Name of a profile in profiles.conf to watch. Second precedence, above a verifier-assigned profile. |
| ETMINAN_PROFILES_PATH | /etc/etminan-agent/profiles.conf | Where named profiles are looked up. |
| ETMINAN_ASSIGNED_PROFILE_PATH | /var/lib/etminan-agent/assigned_profile | Where a profile name pushed by assign-profile is persisted. Only relocate if /var/lib/etminan-agent isn't writable in your deployment. |
| ETMINAN_IMA_LOG_PATH | /sys/kernel/security/ima/ascii_runtime_measurements_sha256 | IMA measurement log to read. Must be the SHA256-bank file, not the generic legacy one. |
| ETMINAN_IMA_POLICY_PATH | /sys/kernel/security/ima/policy | Where write-ima-policy writes the IMA policy. |
Three ways to set watched paths, highest precedence first (per
agent/src/profiles.rs's resolve()):
A direct, comma-separated list inlined in this host's own agent.env.
Overrides everything else — use it for a one-off host that doesn't fit any shared
profile.
A profile name centrally assigned via etminan-verifier
assign-profile (see the command reference below). Persisted locally in
/var/lib/etminan-agent/assigned_profile.
Looks up a named section in profiles.conf. Lets many
hosts share one reusable path set instead of each inlining its own full list. Falls
back to watching nothing (loud warning) if none of the three is set.
profiles.conf is a hand-rolled [name] + one-path-per-line format — no TOML/YAML. Build one up for
two shared fleets, fds-host and myapp-host:
# comments and blank lines are ignored
[fds-host]
/usr/local/bin/fds-proxy
/usr/local/bin/fds-admin
/etc/systemd/system
/etc/fds
[myapp-host]
/usr/local/bin/myapp
/etc/myapp
Set ETMINAN_PROFILE=fds-host in an fds fleet host's
agent.env, ETMINAN_PROFILE=myapp-host
on a myapp host, restart etminan-agent on each — done. Both
fleets share this one file; each host only ever pulls its own section.
Two parsing behaviors are worth knowing before you rely on this file, since neither one produces an error:
[a]\n/one\n[a]\n/two produces
a -> [/one, /two] — both path lists are kept, not the
second replacing the first. If you meant to replace a profile's contents, delete the
old section entirely rather than appending a second one with the same name.
There is no "default" section — a path listed above every [name] header in the file belongs to no profile and is never
swept, with no warning printed. Always put a [name] header
first.
An empty section ([empty-profile] with no paths under it) is
valid — it just watches nothing under that name. To confirm what an agent will actually
resolve before restarting it for real, the same parser backs
etminan-verifier assign-profile's validation: pushing an
unknown name gets refused with the exact list of names the agent found in its file.
Rather than editing every host's agent.env by hand, the
verifier can push a profile selection to a specific enrolled host — the profile's
actual path list stays local to the agent, in its own
profiles.conf. Only the choice of which named profile to
use travels over the network:
A signed operator action — --key/--reason are required, same
accountability bar as baseline approve.
Same pinned mTLS as everything else, but this is its own request/response round
trip — a SetProfile message carrying just the profile
name, never routed through the quote-taking path and never touching the TPM.
Looks up myapp-host in its own
profiles.conf before accepting anything. If the name
doesn't exist there, it refuses and replies with the list of profile names it does
know about — the verifier can push a selection, never new path content, so an
unrecognized name is always the agent's own configuration, not a mistake the verifier
can inject.
On success, writes the selection to
/var/lib/etminan-agent/assigned_profile and replies with
SetProfileAck { applied: true, message: "profile 'myapp-host' assigned" }.
The verifier only records the assignment in its own state.json — signed, audit-logged as
profile_assigned — if ack.applied == true; it never claims success that didn't
actually happen on the agent side.
Watched paths are re-resolved fresh on every quote request, not once at agent startup — so a pushed assignment applies to the next quote taken, no agent restart required.
Every watched path — however it was set — is also picked up for event-triggered write detection: the agent watches for writes via unprivileged fanotify and sweeps a changed file immediately instead of waiting for the next hourly cycle — automatic, no extra config, needs Linux 5.13+ (older kernels fall back to per-cycle-sweep-only, still fully correct, just up to an hour slower).
Three mechanics worth knowing exactly, since none of them are configurable — they're the fixed behavior every watched path goes through:
| Step | What it does |
|---|---|
| Sweep | Before every quote, the agent recursively walks each watched path and reads every file it finds — this is what triggers the kernel's own IMA hook to measure and log it; the agent itself never computes or reports a content hash. There is no exclusion mechanism (no glob, no size/extension filter) — the only way to keep a file out of a cycle is to not list its containing path. A single file's userspace read is capped at 64 MiB (the kernel's own IMA measurement of that file is unaffected by this cap either way). |
| write-ima-policy | Writes exactly two rules to the IMA policy node:
measure func=BPRM_CHECK mask=MAY_EXEC and
measure func=FILE_CHECK mask=MAY_READ fsuuid=<root-fs-uuid>.
Useful to know verbatim if you ever need to sanity-check
/sys/kernel/security/ima/policy against what this command is
supposed to have written, or reason about interaction with a pre-existing custom IMA
policy on the host. |
| Package lookup | A separate, on-demand round trip from the verifier (never mixed with quote-taking) — capped at 200 paths per request (more than that is refused outright with an error, not truncated), each dpkg/rpm subprocess call bounded by a 5-second timeout. A host with neither package manager installed, or a path no installed package owns, comes back as "no package match" with no error — this is the normal, expected case for anything not installed via a system package manager. |
No plaintext fallback, no CA chain to manage. Neither binary does anything useful without a keypair — both refuse to start until one exists.
On the agent host, once:
Writes /var/lib/etminan-agent/tls/{cert,key}.pem
(or $ETMINAN_TLS_DIR if set) and prints the certificate's
SHA-256 fingerprint.
On the verifier device:
Copy the printed fingerprint into
ETMINAN_VERIFIER_CERT_FINGERPRINT in every agent
host's agent.env this verifier will talk to. Without it,
an agent accepts a TLS client certificate from any caller and warns loudly on every
startup.
etminan-verifier enroll captures the agent's
certificate fingerprint on its first connection (trust-on-first-use) and stores it in
state.json alongside the AK fingerprint. Confirm both
out-of-band before trusting the enrollment, same discipline as an SSH host key on
first connect. If the agent's keypair is ever regenerated, re-pin with
etminan-verifier rotate-tls rather than a full
re-enrollment — AK trust and the approved baseline are untouched.
Rotating the verifier's own certificate instead is manual:
keygen-tls, then update
ETMINAN_VERIFIER_CERT_FINGERPRINT in every agent's
agent.env and restart each agent, in that order —
restarting before the env update lands locks that agent out.
Copy deploy/verifier.env.example to
/etc/etminan-verifier/verifier.env and generate an operator
key — it's what makes every approve/reject/exclude tamper-evident.
| Variable | Default | Purpose |
|---|---|---|
| ETMINAN_VERIFIER_STATE | /var/lib/etminan-verifier/state.json | Per-host enrollment state: address, AK fingerprint, cumulative PCR value. |
| ETMINAN_BASELINE_DB | /var/lib/etminan-verifier/baseline.db | SQLite store: approved / pending / rejected / excluded measurements, audit log. |
| ETMINAN_TLS_DIR | /var/lib/etminan-verifier/tls | Where this verifier's own mTLS identity lives. |
| ETMINAN_PENDING_REVIEW_SLA_HOURS | 24 | How long a pending item can sit unreviewed before it's escalated to a warning-severity finding. |
| ETMINAN_NOTIFY_EMAIL | unset | Destination for alarm emails. Unset disables the email leg only — the structured log and audit_log still happen. |
| ETMINAN_NOTIFY_FROM | unset | From-address for alarm emails. |
| ETMINAN_NOTIFY_EMAIL_SEVERITIES | all | Comma-separated severities that trigger an email; unset means every severity. |
| ETMINAN_NOTIFY_CHANNELS | unset | Comma-separated plugin channel names to enable (e.g. pagerduty,slack). Unset = feature fully off. |
| ETMINAN_NOTIFY_PLUGINS_CONF | /etc/etminan-verifier/notify-plugins.conf | Allowlist file for notify plugins. |
| ETMINAN_NOTIFY_<CHANNEL>_SEVERITIES | all | Per-channel severity filter, e.g. ETMINAN_NOTIFY_PAGERDUTY_SEVERITIES=critical. |
| ETMINAN_CHANGE_SOURCES | unset | Comma-separated change-source plugin names to enable. Unset = feature fully off, no plugin ever executed. |
| ETMINAN_CHANGE_SOURCE_PLUGINS_CONF | /etc/etminan-verifier/change-source-plugins.conf | Allowlist file for change-source plugins. |
Plugin processes themselves read their own variables from this same environment (never
the verifier binary) — e.g. ETMINAN_RT_BASE_URL/ETMINAN_RT_API_TOKEN for the Request Tracker reference plugin, or
ETMINAN_PAGERDUTY_ROUTING_KEY/ETMINAN_SLACK_WEBHOOK_URL/ETMINAN_WEBHOOK_URL
for the shipped notify plugins. See the Plugin API doc and each
reference plugin's own comments for the full list.
Takes a first quote, validates it self-consistently (refuses to enroll on a
malformed/inconsistent quote rather than seed a bad baseline), and prints the AK
fingerprint. Confirm that fingerprint out-of-band — e.g. read it off the
host's console — before trusting it; this is the one trust-on-first-use moment in the
whole design. --key/--reason are
required: the enrollment decision itself is signed and recorded in the audit log.
Every measurement from that first quote is recorded as pending, never auto-trusted — review and approve it to seed the baseline:
When a pending measurement shows up in baseline review,
package ownership answers "does this look like an install." Change-source correlation
answers the harder question: was there a change ticket, maintenance window, or approved
deploy that explains it? Purely informational — it never gates or auto-approves anything.
A pending change is still only ever accepted via an explicit, signed
baseline approve.
For each pending measurement, the verifier runs every plugin named in
ETMINAN_CHANGE_SOURCES as
<plugin> --host <host_id> --anchor <RFC3339
timestamp> — the anchor is that measurement's first-seen time. The plugin itself
queries the external system (e.g. Request Tracker's REST2 API) for anything matching that
host within a time window around the anchor, and prints a JSON array of matches to stdout
([] for "checked, nothing found"). This runs entirely on the
verifier, not the agent — a ticket system is a service the verifier can reach directly, so
there's no agent round trip involved. Every configured and allowlisted source runs, and
every match any of them finds is kept — never just the first.
Off by default — ETMINAN_CHANGE_SOURCES unset
means no plugin is ever executed. To turn it on:
Add the name, path, and that hash as one line in
/etc/etminan-verifier/change-source-plugins.conf:
In /etc/etminan-verifier/verifier.env — these are read
by the plugin, not the verifier binary:
ETMINAN_RT_BASE_URL must be https:// —
the plugin sends the API token in a plain Authorization
header on every request and refuses to run at all against an http:// base URL rather than send that token in cleartext.
RT's TicketSQL date-literal format and your instance's actual custom field name are the two things this integration cannot know in advance — confirm them once:
Paste that TicketSQL into RT's own web search UI (Search → Tickets → Advanced) and confirm it returns what you expect. If RT interprets the timestamps in a different timezone, or your custom field name doesn't match, this is where you'll see it — before it silently returns zero matches during a real review.
Once configured, a second line appears under each pending entry's existing package-ownership verdict:
If nothing matched, or correlation hasn't run yet for this host, you'll see one of:
If more than one configured source matches, every one is listed:
None of this changes what baseline approve/reject do — it's context for the operator making that call, not an
input to it. Adding a second ticket system is the same plugin recipe as any other category:
see the Plugin API and the full
change-source worked example.
etminan-verifier run always prints every finding, and emails
them too if ETMINAN_NOTIFY_EMAIL is set — that leg is
built-in, not a plugin, and predates this feature. PagerDuty, Slack, and a generic
webhook are additional channels, each an external plugin process under the same
Etminan Plugin API as change-source correlation — no in-process
HTTP client exists in the verifier at all.
At the end of a check/run cycle,
if there's at least one finding, the verifier runs every plugin named in
ETMINAN_NOTIFY_CHANNELS. Each finding is first filtered against
that channel's own ETMINAN_NOTIFY_<CHANNEL>_SEVERITIES
(unset means every severity reaches that channel), then the surviving findings are piped as
one JSON array on the plugin's stdin — never argv, since finding text is
free-form and shouldn't be shell-escaped into a command line:
[
{"host_id": "web-01", "text": "quote signature invalid", "severity": "critical"},
{"host_id": "web-02", "text": "3 pending item(s) aged past the 24h review SLA", "severity": "warning"}
]
A channel's exit code is the only signal read back: 0 means it
fired successfully, non-zero means "didn't fire this cycle" — logged, never retried, never
allowed to block the rest of the cycle or the other configured channels.
| Plugin | Behavior | Needs |
|---|---|---|
| pagerduty.sh | One Events API v2 event per finding, with dedup_key =
"attest-<host_id>" — repeat findings for the same host correlate into one
incident instead of paging fresh every cycle. |
ETMINAN_PAGERDUTY_ROUTING_KEY |
| slack.sh | One digest message for the whole batch — a noisy cycle with many findings is still a single Slack message, not a flood. | ETMINAN_SLACK_WEBHOOK_URL |
| webhook.sh | Passes the findings array through unmodified as the POST body — the generic escape hatch, and the template to copy for a fully custom integration. | ETMINAN_WEBHOOK_URL, optional
ETMINAN_WEBHOOK_AUTH_HEADER |
Only install the channels you're actually enabling.
Add each as one line in
/etc/etminan-verifier/notify-plugins.conf:
In /etc/etminan-verifier/verifier.env:
ETMINAN_NOTIFY_CHANNELS is the master switch: unset or
empty, this feature never executes any plugin at all. Every other setting only
matters once a channel's name is both listed here and allowlisted in the
previous step.
Do this right after setup — don't wait for a real finding
to discover a typo'd hash or a missing routing key. This same check also runs
automatically at the start of every run cycle; a failure
there becomes an ordinary warning-severity finding, so a broken notify channel can
never fail silently.
Adding a channel that isn't shipped (Microsoft Teams, SMS, an internal on-call system) is the same plugin recipe as any other category — write a script that reads the findings JSON from stdin and exits non-zero on failure: see the Plugin API and the full notification-channels worked example.
This is the complete surface of both CLIs — nothing else exists. Running either binary with no arguments (verifier) or an unrecognized subcommand prints this same list.
No subcommand: runs the long-running daemon. Loads its mTLS keypair, checks watched paths resolve (warns loudly, doesn't refuse to start, if they don't), starts the fanotify write-watcher, and listens for quote/package-lookup requests from its verifier.
Generates this host's mTLS keypair once, under $ETMINAN_TLS_DIR
(default /var/lib/etminan-agent/tls), and prints its SHA-256
fingerprint for pinning on the verifier side.
Writes the IMA measurement policy to $ETMINAN_IMA_POLICY_PATH
(default /sys/kernel/security/ima/policy). Meant to run once,
early at boot, via etminan-agent-ima-policy.service — not
something to run interactively on a host that's already measuring. Idempotent: an
already-active policy this boot is treated as success, not an error.
Same as the agent's version — generates the verifier's own mTLS keypair and prints its fingerprint. Run once per verifier device.
Generates a new Ed25519 operator signing key and writes it to <path>. This is the key passed as --key to every trust-changing command below — keep it safe, back
it up, and never commit it to a repo.
Enrolls a new monitored host: takes and validates a first quote, prints its AK fingerprint for out-of-band confirmation, and records every subsequent measurement as pending review. See Enrolling a host above for the full walkthrough.
Checks one already-enrolled host interactively, outside the hourly schedule — useful
right after a change to confirm the result immediately rather than waiting for the next
run. No --key: this is a
read-only check, not a trust-changing action.
The main scheduled command — normally invoked by etminan-verifier.timer, hourly. Checks every enrolled host,
diffs against the approved baseline, correlates package ownership and any configured
change-source/notify plugins, escalates stale pending items past the SLA, and fires the
alarm path (print, email, notify plugins, audit log) on any finding. Also runs
plugins verify automatically every cycle.
Pushes a watched-path profile selection to a host, rather than editing that
host's agent.env by hand — the profile's actual contents
still live in the agent's own profiles.conf. A signed
operator action, same accountability bar as baseline approve.
Re-pins a host's TLS certificate fingerprint after its agent's mTLS keypair was regenerated — without a full re-enrollment. Requires the AK fingerprint to still match; PCR replay continues from the stored value. Signed and audit-logged like every other trust-changing command.
Lists pending measurements. Each prints a one-sentence plain-language verdict first
(e.g. "Likely a legitimate install — matches package nginx v1.24.0"), then
change-ticket correlation if configured, then package-transaction/trust-store detail,
with raw sha256/owner/size/mtime underneath for anyone who wants to double-check. Omit
--host to review every host at once.
Approves every pending item for one host in a single signed batch.
--reason is folded into the signed payload — tamper-evident,
not just a log string.
Fleet-wide form: approves every pending measurement matching a path/glob and exact hash across every host at once — for a change that produced the identical file on many hosts.
Rejects a specific measurement — fires the alarm path immediately as a confirmed incident, not a soft warning. Same signing requirement as approve: a rejection is a trust decision too.
Creates a suppression rule for a path that's expected to churn and shouldn't generate
pending review at all. An exact path needs no confirmation; a trailing
* glob prints how many currently-known paths it would match
and refuses to proceed without --confirm yes — a broad glob
widens what's silently excluded going forward, so it can't be created by accident.
A trailing * is a plain string prefix match, nothing more —
/etc* also matches an unrelated sibling like
/etcxyz/anything, not just paths under /etc/. This is exactly why the preview step exists: it lists every
currently-known path the pattern would actually match, so read that list before adding
--confirm yes rather than assuming the pattern means what it
looks like it means. If you want "everything under this directory," write the pattern
with the trailing slash included: --path /etc/*.
Lists every active exclusion rule with its id, pattern, reason, and who created it.
Revokes an exclusion rule by id. Signed like creation — revoking re-exposes whatever the rule was hiding to review, exactly as consequential a decision as creating one.
Re-checks every approval batch's signature, every exclusion rule's create/revoke
signature, and the full hash-chained audit-log in one pass. Entries written before this
feature existed show up as "unsigned, predates this feature" rather than a failure. Run
on demand, or schedule alongside run as a detective control.
Checks every plugin currently referenced by ETMINAN_CHANGE_SOURCES
or ETMINAN_NOTIFY_CHANNELS: confirms each has an allowlist
entry and passes the full security check (root-owned, not group/world-writable, content
hash matches the pinned SHA-256) — without executing it. Exits non-zero and prints
exactly what's wrong if anything fails. Also run automatically at the start of every
run cycle.
etminan-verifier.timer calls run
hourly. It confirms the presented AK still matches enrollment, the signature is valid, and
the replayed IMA log's digest matches the signed PCR10 — any failure is a critical finding,
fed straight to the alarm path and recorded in the audit log. Unchanged measurements are
silent; new or changed ones go to pending_review. Any pending
item older than the review SLA (24h by default) is escalated to a warning-severity
finding — pending review is never a silent, indefinite state.
When package-ownership correlation finds a match, baseline
review shows a third line beyond ownership: whether this exact version arrived via
a real, timely, authenticated transaction — e.g. "✓ installed via an authenticated apt
transaction at 2026-07-21 10:00:00." or, on an rpm host, "✓ signed with trusted key
a1b2c3d4e5f6a7b8." A mismatch is a loud "⚠ ... — treat with suspicion." rather
than a soft note. Note dpkg's line never claims a signature check — only whether the
install went through APT's own authentication — the wording says so explicitly rather than
implying rpm-level confidence it doesn't have.
Every command that changes trust-relevant state — enroll,
baseline approve/reject/exclude create/revoke, assign-profile, rotate-tls — is signed
with the operator key passed via --key and appended to a
self-contained, hash-chained audit log: deleting, editing, or reordering any past entry
breaks the chain for everything recorded after it. No external tool or extra key is
involved. Verify the whole store on demand with baseline
verify-signatures.
No agent code change needed. Edit
/etc/etminan-agent/profiles.conf (copy from
deploy/profiles.conf.example first) — a hand-rolled
[name] + one-path-per-line format, # comments and blank lines ignored:
Then set ETMINAN_PROFILE=myapp-host
in that host's agent.env (or push it centrally with
assign-profile) and restart etminan-agent.
Startup warns, but doesn't refuse to start, if any listed path doesn't exist.
Package-ownership lookups are pluggable via the
PackageChecker trait in agent/src/package_lookup.rs. Dpkg and
Rpm are the two existing implementations — each shells out to
that manager's query command and parses the one line it needs, returning
None on any failure rather than panicking. Add a new struct
implementing the trait and one line in the checkers() list —
nothing else in the verifier or protocol needs to change.
Change-ticket correlation and alarm/notification channels are both external, allowlisted, hash-pinned plugin processes — no Rust code, no verifier rebuild, ever. Write a script matching the category's contract, allowlist it by name/path/pinned SHA-256, and reference its name in an environment variable:
Request Tracker ships as the reference change-source plugin; PagerDuty, Slack, and a generic webhook ship as reference notify plugins. Full contract, worked examples, and the shared security model: Plugin API, change-source correlation, notification channels.
Everything above is enough to operate the tool day to day through its own commands. This
section is for a different reader: someone writing a backup/migration script, an
independent compliance report, or a signature check that doesn't go through
etminan-verifier itself.
One JSON object per enrolled host, keyed by host id, at $ETMINAN_VERIFIER_STATE (default
/var/lib/etminan-verifier/state.json). Written atomically
(temp file + fsync + rename) on every update.
| Field | Meaning |
|---|---|
| addr | Host's ip:port, recorded at enroll time so run never needs --addr re-supplied. |
| ak_public_marshaled_hex / ak_fingerprint_sha256_hex | The enrolled AK public key and its SHA-256 fingerprint — every later quote is checked against this. |
| tls_cert_fingerprint_sha256_hex | The agent's pinned TLS certificate fingerprint from the TOFU ceremony. Empty for hosts enrolled before mTLS existed — those need re-enrollment. |
| cumulative_pcr10_hex / last_log_offset | Replay state: the running PCR10 value and the IMA log offset already consumed. |
| assigned_profile / _reason / _signed_by / _signature_hex | The profile name last pushed by assign-profile, and the operator accountability for that push. |
| enrolled_by / enrolled_reason / enrollment_signed_payload / enrollment_signature_hex | Who enrolled this host, why, and the signed proof of that decision. |
| tls_rotated_by / _reason / _signed_payload / _signature_hex | Accountability for the most recent rotate-tls — only the latest; the full history is in audit_log. |
At $ETMINAN_BASELINE_DB (default
/var/lib/etminan-verifier/baseline.db), WAL mode — back up the
-wal/-shm sidecars alongside it.
| Table | Key columns |
|---|---|
| approved_measurements | (host_id, path) primary key; sha256, baseline_batch_id, approved_by, approved_at. |
| pending_review | host_id, path, sha256, previous_sha256, file metadata (uid/gid/mode/mtime/size), package-correlation fields (package_name/_version/_manager/_transaction_time/_trust_verified/_trust_detail), first_seen_at, change_checked_at. Unique on (host_id, path, sha256). |
| rejected | host_id, path, sha256, rejected_at. Unique on (host_id, path, sha256) — idempotent re-rejection. |
| exclusion_rules | path_pattern, reason, created_by/_at, revoked_at (null while active). |
| audit_log | seq, recorded_at, actor, action, resource, severity, detail, prev_hash, entry_hash — see the hash-chain reference below. |
| baseline_batches | batch_id, host_id/match_pattern (whichever form of approve was used), signed_payload, signature_hex, approved_by/_at, reason. |
| excluded_log | host_id, path, sha256, matched_pattern, observed_at — every measurement an exclusion rule ever suppressed, for audit purposes. |
| change_correlations | host_id, checked_at, source (the plugin name), change_id, summary, status, url. |
Every signed action's payload is deterministic ASCII/UTF-8 text, NUL-separated
(\0) between fields and newline-terminated per entry — built
this way specifically so the exact bytes signed can be reconstructed and re-verified
outside this tool (e.g. with a Python or openssl Ed25519 check), not just trusted because
the tool says so.
| Action | Payload format |
|---|---|
| baseline approve | REASON\0<reason>\n then one <host_id>\0<path>\0<sha256>\n line per entry, entries sorted lexicographically first — so the same batch always signs identically regardless of processing order. |
| baseline exclude create/revoke | ACTION\0<create|revoke>\nPATTERN\0<pattern>\nREASON\0<reason>\n — the action word is baked in so a "create" signature can never be replayed to satisfy a "revoke" of the same rule. |
| baseline reject | <host_id>\0<path>\0<sha256>\0<reason>\n |
| enroll | <host_id>\0<ak_fingerprint>\0<tls_fingerprint>\0<reason>\n |
| rotate-tls | <host_id>\0<old_tls_fingerprint>\0<new_tls_fingerprint>\0<reason>\n |
| assign-profile | <host_id>\0<profile_name>\0<reason>\n |
Signatures are Ed25519 over the exact payload bytes above, hex-encoded (128 hex chars). The
operator key itself (keygen --out <path>) is a raw 32-byte
seed, hex-encoded (64 hex chars) at file mode 0600, with the matching public key written
alongside at <path>.pub (also 64 hex chars) — no PEM, no
DER, nothing but hex text either side.
Each row's entry_hash is
SHA256(canonical_json({seq, recorded_at, actor, action, resource,
severity, detail_canonical: detail, prev_hash})), hex-encoded. "Canonical JSON" means
object keys sorted lexicographically at every nesting level and no whitespace between
tokens — deterministic regardless of what produced the row. The very first row's
prev_hash is 68 zero characters (the genesis sentinel, not a
real hash of anything). baseline verify-signatures already walks
this chain for you; the formula above is what you'd reimplement to check it with an
independent script instead of trusting this binary's own verification.
Only two values exist anywhere in this project — there is no "info" or "error" tier for findings, by design:
| Severity | What triggers it |
|---|---|
| critical | A genuine verification failure (invalid signature, AK/TLS fingerprint mismatch, PCR digest that doesn't match the replayed log, an unreachable host) or an operator baseline reject. |
| warning | A pending item aged past the review SLA, or a configured plugin failing its plugins verify check. |
Both values are valid in every severity-filter env var —
ETMINAN_NOTIFY_EMAIL_SEVERITIES and every
ETMINAN_NOTIFY_<CHANNEL>_SEVERITIES — as a comma-separated
list.
Neither ETMINAN_WATCHED_PATHS nor
ETMINAN_PROFILE is set, or the named profile doesn't exist
in the profiles file — check the startup warning, which names the missing profile and
lists known ones.
Either a fabricated log (check the host's IMA violations counter) or a log/offset desync. Re-enroll to establish a fresh baseline.
The host is presenting an AK that doesn't match enrollment — re-enroll after confirming out-of-band why the AK changed (TPM replaced, host reimaged, or something more concerning).
Likely a TLS certificate fingerprint mismatch — check whether the agent's
TLS keypair was regenerated (keygen-tls) without a matching
rotate-tls on this side. Confirm out-of-band that the new
certificate is legitimate before running rotate-tls, same
discipline as enrollment.
run escalates it to a warning-severity finding
automatically; review and approve/reject it rather than letting it accumulate. Set
ETMINAN_PENDING_REVIEW_SLA_HOURS if 24h doesn't match your
review cadence.
Run etminan-verifier plugins verify — it
checks allowlist membership, ownership, permissions, and content hash for every
currently-referenced plugin and prints exactly which check failed.