etminan
User guide

Everything an operator needs, start to finish.

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.

01 — Who runs what

A single host is never both.

The verifier's entire trust value comes from being administratively separate from the hosts it checks — never share a machine between the two roles.

Monitored host

etminan-agent

A relay, not a trust anchor. Needs a TPM 2.0 device (physical or vTPM) and the kernel's IMA subsystem enabled.

  • Sweeps watched paths, takes a signed TPM quote, relays it with the IMA log delta.
  • Makes no pass/fail decision and sends no alerts — that's the verifier's job.
Separate device

etminan-verifier

No TPM needed. Polls every enrolled agent on a schedule and is the only place an alarm ever fires from.

  • Offline signature check + PCR replay against the approved baseline.
  • Zero TPM library dependency in the shipped binary — cross-compiles clean to a static musl target.
02 — Installing

From a fresh host to two running binaries.

From the .deb package

$ sudo dpkg -i etminan_<version>_amd64.deb

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:

# Monitored host (needs a TPM):
$ sudo systemctl enable --now etminan-agent-ima-policy.service etminan-agent.service
# Verifier device:
$ sudo systemctl enable --now etminan-verifier.timer

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.

03 — Configuring an agent host

Watched paths are config, never a hardcoded default.

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.

VariableDefaultPurpose
ETMINAN_AGENT_LISTEN0.0.0.0:7620Listen address for quote requests from the verifier.
ETMINAN_TLS_DIR/var/lib/etminan-agent/tlsWhere this agent's own mTLS keypair lives (generated once via keygen-tls).
ETMINAN_VERIFIER_CERT_FINGERPRINTunsetPins which verifier this agent accepts connections from. Unset accepts any client certificate and warns loudly at every startup.
ETMINAN_WATCHED_PATHSunsetDirect, comma-separated path list. Highest precedence of the three ways to set watched paths.
ETMINAN_PROFILEunsetName of a profile in profiles.conf to watch. Second precedence, above a verifier-assigned profile.
ETMINAN_PROFILES_PATH/etc/etminan-agent/profiles.confWhere named profiles are looked up.
ETMINAN_ASSIGNED_PROFILE_PATH/var/lib/etminan-agent/assigned_profileWhere 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_sha256IMA measurement log to read. Must be the SHA256-bank file, not the generic legacy one.
ETMINAN_IMA_POLICY_PATH/sys/kernel/security/ima/policyWhere write-ima-policy writes the IMA policy.

Watched-path precedence

Three ways to set watched paths, highest precedence first (per agent/src/profiles.rs's resolve()):

ETMINAN_WATCHED_PATHS

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.

Verifier-pushed assigned 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.

ETMINAN_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.

Writing a profiles.conf, worked example

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:

/etc/etminan-agent/profiles.conf
# 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 repeated [name] section is additive, not "last one wins"

[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.

A path line before the first [name] header is silently dropped

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.

How the centralized profile registry works

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:

The operator runs assign-profile

$ etminan-verifier assign-profile --host web-01 --profile myapp-host \
--key operator.key --reason "onboarding myapp fleet"

A signed operator action — --key/--reason are required, same accountability bar as baseline approve.

The verifier opens a dedicated connection

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.

The agent validates it locally

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.

The agent persists and acknowledges

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.

It takes effect on the very next cycle

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).

What actually happens during a cycle

Three mechanics worth knowing exactly, since none of them are configurable — they're the fixed behavior every watched path goes through:

StepWhat 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.
04 — Setting up mTLS

Every connection is mutual TLS, pinned by fingerprint.

No plaintext fallback, no CA chain to manage. Neither binary does anything useful without a keypair — both refuse to start until one exists.

Generate the agent's keypair

On the agent host, once:

$ etminan-agent keygen-tls

Writes /var/lib/etminan-agent/tls/{cert,key}.pem (or $ETMINAN_TLS_DIR if set) and prints the certificate's SHA-256 fingerprint.

Generate the verifier's keypair

On the verifier device:

$ etminan-verifier keygen-tls

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.

Pin the agent's certificate

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.

05 — Configuring the verifier

State, baseline, and an operator signing key.

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.

VariableDefaultPurpose
ETMINAN_VERIFIER_STATE/var/lib/etminan-verifier/state.jsonPer-host enrollment state: address, AK fingerprint, cumulative PCR value.
ETMINAN_BASELINE_DB/var/lib/etminan-verifier/baseline.dbSQLite store: approved / pending / rejected / excluded measurements, audit log.
ETMINAN_TLS_DIR/var/lib/etminan-verifier/tlsWhere this verifier's own mTLS identity lives.
ETMINAN_PENDING_REVIEW_SLA_HOURS24How long a pending item can sit unreviewed before it's escalated to a warning-severity finding.
ETMINAN_NOTIFY_EMAILunsetDestination for alarm emails. Unset disables the email leg only — the structured log and audit_log still happen.
ETMINAN_NOTIFY_FROMunsetFrom-address for alarm emails.
ETMINAN_NOTIFY_EMAIL_SEVERITIESallComma-separated severities that trigger an email; unset means every severity.
ETMINAN_NOTIFY_CHANNELSunsetComma-separated plugin channel names to enable (e.g. pagerduty,slack). Unset = feature fully off.
ETMINAN_NOTIFY_PLUGINS_CONF/etc/etminan-verifier/notify-plugins.confAllowlist file for notify plugins.
ETMINAN_NOTIFY_<CHANNEL>_SEVERITIESallPer-channel severity filter, e.g. ETMINAN_NOTIFY_PAGERDUTY_SEVERITIES=critical.
ETMINAN_CHANGE_SOURCESunsetComma-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.confAllowlist 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.

# Generate the operator signing key once, keep it somewhere safe.
$ etminan-verifier keygen --out /etc/etminan-verifier/operator.key
06 — Enrolling a host

One trust-on-first-use moment, then everything is pinned.

$ etminan-verifier enroll --host web-01 --addr 10.0.0.5:7620 \
--key operator.key --reason "onboarding web-01"

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:

$ etminan-verifier baseline review --host web-01
$ etminan-verifier baseline approve --host web-01 \
--key /etc/etminan-verifier/operator.key --reason "initial baseline for web-01"
07 — Change-source correlation

Was this change actually planned? Ask the ticket system.

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.

How it gathers information

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.

Setting up the shipped Request Tracker plugin

Off by default — ETMINAN_CHANGE_SOURCES unset means no plugin is ever executed. To turn it on:

Install the plugin, root-owned

$ sudo install -D -m 0755 -o root -g root \
deploy/change-sources/request-tracker.sh \
/etc/etminan-verifier/change-sources/request-tracker.sh

Allowlist it with its pinned hash

$ sha256sum /etc/etminan-verifier/change-sources/request-tracker.sh

Add the name, path, and that hash as one line in /etc/etminan-verifier/change-source-plugins.conf:

request-tracker /etc/etminan-verifier/change-sources/request-tracker.sh <sha256sum output>

Configure RT access and enable it

In /etc/etminan-verifier/verifier.env — these are read by the plugin, not the verifier binary:

ETMINAN_CHANGE_SOURCES=request-tracker
ETMINAN_RT_BASE_URL=https://rt.example.com
ETMINAN_RT_API_TOKEN=1-24-abcdef0123456789...
# optional — omit to fall back to a Subject-only text search
ETMINAN_RT_HOST_FIELD=Host
# default shown; widen for maintenance-weekend changes
ETMINAN_RT_WINDOW_HOURS=24

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.

Verify it checks out

$ etminan-verifier plugins verify

Confirm the query before trusting it

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:

$ ETMINAN_RT_DEBUG_QUERY=1 etminan-verifier check --host web-01 --addr 10.0.0.5:7620
etminan-verifier: RT TicketSQL query for host 'web-01': (CF.{Host} = 'web-01' OR Subject LIKE 'web-01')
AND (LastUpdated > '2026-07-20 14:00:00' AND LastUpdated < '2026-07-22 14:00:00')

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.

What it looks like in baseline review

Once configured, a second line appears under each pending entry's existing package-ownership verdict:

[42] host=web-01 path=/usr/local/bin/fds-proxy
Likely a legitimate install — matches package fds-proxy v2.3.1. Confirm this deploy was expected before approving.
Matches change RT#4821 (resolved) via request-tracker: 'Patch Tuesday rollout — web fleet' — confirm this change was expected.
detail: sha256=... (was ...) owner=uid:0/gid:0 mode=0755 size=... mtime=... first_seen=...

If nothing matched, or correlation hasn't run yet for this host, you'll see one of:

No approved change ticket found near this host for this time window across any configured system — verify this wasn't unplanned.
Change-source correlation not yet checked for this host — re-run `check`/`run` for this host.

If more than one configured source matches, every one is listed:

Matches 2 possible confirmations across configured systems: RT#4821 (resolved) via request-tracker; CHG0093 (approved) via servicenow

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.

08 — Notification channels

How an alarm actually reaches somebody.

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.

When and how a channel is executed

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:

what a notify plugin receives on stdin
[
  {"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.

What each shipped reference plugin actually does with that input

PluginBehaviorNeeds
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

Setting up a channel

Install the plugin(s), root-owned

$ sudo install -D -m 0755 -o root -g root \
deploy/notify-plugins/pagerduty.sh \
/etc/etminan-verifier/notify-plugins/pagerduty.sh

Only install the channels you're actually enabling.

Allowlist each one with its pinned hash

$ sha256sum /etc/etminan-verifier/notify-plugins/pagerduty.sh

Add each as one line in /etc/etminan-verifier/notify-plugins.conf:

pagerduty /etc/etminan-verifier/notify-plugins/pagerduty.sh <sha256sum output>
slack /etc/etminan-verifier/notify-plugins/slack.sh <sha256sum output>

Enable and configure each channel

In /etc/etminan-verifier/verifier.env:

ETMINAN_NOTIFY_CHANNELS=pagerduty,slack
ETMINAN_PAGERDUTY_ROUTING_KEY=...
ETMINAN_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
# optional: page only on critical findings, let slack see everything
ETMINAN_NOTIFY_PAGERDUTY_SEVERITIES=critical

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.

Verify before trusting it

$ etminan-verifier plugins verify

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.

09 — Command reference

Every subcommand either binary ships with.

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.

etminan-agent

etminan-agent

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.

$ etminan-agent
etminan-agent: watched paths at startup: ["/usr/local/bin/fds-proxy", ...]
etminan-agent: listening on 0.0.0.0:7620
etminan-agent keygen-tls

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.

$ etminan-agent keygen-tls
wrote /var/lib/etminan-agent/tls/{cert,key}.pem
fingerprint: 3a1f...c2
etminan-agent write-ima-policy

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.

$ sudo etminan-agent write-ima-policy

etminan-verifier — lifecycle

etminan-verifier keygen-tls

Same as the agent's version — generates the verifier's own mTLS keypair and prints its fingerprint. Run once per verifier device.

$ etminan-verifier keygen-tls
etminan-verifier keygen --out <path>

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.

$ etminan-verifier keygen --out /etc/etminan-verifier/operator.key
etminan-verifier enroll --host <id> --addr <ip:port> --key <path> --reason <text>

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.

$ etminan-verifier enroll --host web-01 --addr 10.0.0.5:7620 --key operator.key --reason "onboarding web-01"
etminan-verifier check --host <id> --addr <ip:port>

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.

$ etminan-verifier check --host web-01 --addr 10.0.0.5:7620
etminan-verifier run

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.

$ etminan-verifier run
etminan-verifier assign-profile --host <id> --profile <name> --key <path> --reason <text>

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.

$ etminan-verifier assign-profile --host web-01 --profile myapp-host \
--key operator.key --reason "onboarding myapp fleet"
etminan-verifier rotate-tls --host <id> --key <path> --reason <text>

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.

$ etminan-verifier rotate-tls --host web-01 --key operator.key --reason "agent TLS keypair regenerated after host reimage"

etminan-verifier — baseline

etminan-verifier baseline review [--host <id>]

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.

$ etminan-verifier baseline review --host web-01
etminan-verifier baseline approve --host <id> --key <path> --reason <text>

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.

$ etminan-verifier baseline approve --host web-01 --key operator.key --reason "patch Tuesday rollout"
etminan-verifier baseline approve --matching <path|prefix*> --hash <sha256> --key <path> --reason <text>

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.

$ etminan-verifier baseline approve --matching '/usr/local/bin/fds-*' --hash <sha256> \
--key operator.key --reason "fds 2.3.1 rollout"
etminan-verifier baseline reject --host <id> --path <path> --hash <sha256> --key <path> --reason <text>

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.

$ etminan-verifier baseline reject --host web-01 --path /usr/local/bin/fds-proxy \
--hash <sha256> --key operator.key --reason "not part of the planned rollout"
etminan-verifier baseline exclude create --path <path|prefix*> --reason <text> --key <path> [--confirm yes]

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.

$ etminan-verifier baseline exclude create --path /var/log/myapp/current.log \
--reason "rotates hourly, content never security-relevant" --key operator.key

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

etminan-verifier baseline exclude list

Lists every active exclusion rule with its id, pattern, reason, and who created it.

$ etminan-verifier baseline exclude list
etminan-verifier baseline exclude revoke --id <n> --key <path> --reason <text>

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.

$ etminan-verifier baseline exclude revoke --id 3 --key operator.key --reason "no longer needed"
etminan-verifier baseline verify-signatures

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.

$ etminan-verifier baseline verify-signatures

etminan-verifier — plugins

etminan-verifier plugins verify

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 plugins verify
10 — Day-to-day operation

Read the verdict, act on it, sign what you approve.

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.

11 — Extending

Three extension points, none of them need a redesign.

A new watched-path profile

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:

[myapp-host]
/usr/local/bin/myapp
/etc/myapp

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.

Another package manager

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.

A ticket system or alert channel — the Etminan Plugin API

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:

# sha256sum the plugin, then add it to the allowlist file:
$ sha256sum /etc/etminan-verifier/notify-plugins/my-plugin.sh
# notify-plugins.conf: <name> <path> <sha256>
# then enable it and verify:
$ etminan-verifier plugins verify

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.

12 — Data & signature reference

For anyone building tooling on top of this, not just using the CLI.

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.

state.json — per-host enrollment state

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.

FieldMeaning
addrHost's ip:port, recorded at enroll time so run never needs --addr re-supplied.
ak_public_marshaled_hex / ak_fingerprint_sha256_hexThe enrolled AK public key and its SHA-256 fingerprint — every later quote is checked against this.
tls_cert_fingerprint_sha256_hexThe 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_offsetReplay state: the running PCR10 value and the IMA log offset already consumed.
assigned_profile / _reason / _signed_by / _signature_hexThe profile name last pushed by assign-profile, and the operator accountability for that push.
enrolled_by / enrolled_reason / enrollment_signed_payload / enrollment_signature_hexWho enrolled this host, why, and the signed proof of that decision.
tls_rotated_by / _reason / _signed_payload / _signature_hexAccountability for the most recent rotate-tls — only the latest; the full history is in audit_log.

baseline.db — SQLite schema

At $ETMINAN_BASELINE_DB (default /var/lib/etminan-verifier/baseline.db), WAL mode — back up the -wal/-shm sidecars alongside it.

TableKey columns
approved_measurements(host_id, path) primary key; sha256, baseline_batch_id, approved_by, approved_at.
pending_reviewhost_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).
rejectedhost_id, path, sha256, rejected_at. Unique on (host_id, path, sha256) — idempotent re-rejection.
exclusion_rulespath_pattern, reason, created_by/_at, revoked_at (null while active).
audit_logseq, recorded_at, actor, action, resource, severity, detail, prev_hash, entry_hash — see the hash-chain reference below.
baseline_batchesbatch_id, host_id/match_pattern (whichever form of approve was used), signed_payload, signature_hex, approved_by/_at, reason.
excluded_loghost_id, path, sha256, matched_pattern, observed_at — every measurement an exclusion rule ever suppressed, for audit purposes.
change_correlationshost_id, checked_at, source (the plugin name), change_id, summary, status, url.

Signed action payloads

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.

ActionPayload format
baseline approveREASON\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/revokeACTION\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.

audit_log — the hash chain, and how to recompute it independently

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.

Alarm severities

Only two values exist anywhere in this project — there is no "info" or "error" tier for findings, by design:

SeverityWhat triggers it
criticalA 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.
warningA 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.

13 — Troubleshooting

What a given symptom usually means.

Agent watches nothing at startup

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.

FINDING: ... PCR digest mismatch

Either a fabricated log (check the host's IMA violations counter) or a log/offset desync. Re-enroll to establish a fresh baseline.

FINDING: ... AK fingerprint mismatch

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).

FINDING: ... unreachable, but the agent is actually up

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.

Pending item stuck past the review SLA (24h by default)

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.

A configured plugin never seems to run

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.