etminan
Architecture & design

How Etminan splits measurement from judgment.

Every design decision below follows from one constraint: the monitored host must never be trusted to grade its own homework. This page walks through the trust boundary, the exact data that crosses it, the baseline/approval workflow, and the security properties that hold even if root on the monitored host is fully compromised. For full narrative detail and the phase-by-phase validation record, see the full design record.

01 — Trust model

Two administratively separate machines, one narrow channel between them.

A single host is never both agent and verifier. The verifier's entire trust value comes from being something the monitored host cannot reach, patch, or silence.

TRUST BOUNDARY — administratively separate MONITORED HOST Linux IMA measures execs/reads TPM 2.0 extends PCR 10 etminan-agent relay only — no pass/fail decision, no alerts requires: TPM device + IMA enabled non-root, group "tss", CAP_DAC_READ_SEARCH only VERIFIER (separate device) etminan-verifier no TPM needed — offline signature math only baseline.db approved + pending + audit_log state.json per-host enrollment + PCR replay state Alarm hash-chained audit log + optional email mutual TLS pinned fingerprint
Both directions of the mTLS channel are authenticated — the agent proves its identity to the verifier, and the verifier proves its identity to the agent. Neither side falls back to plaintext or an unpinned certificate once enrolled.

The verifier can be a laptop in a locked drawer. It never listens for inbound connections from anything but its own enrolled agents (pinned by certificate fingerprint), and its own identity is pinned back at the agent the moment enroll runs — a single trust-on-first-use ceremony, confirmed out-of-band by a human, exactly like an SSH host key. After that, a compromised host can go silent or refuse to answer, but it cannot forge a validly signed quote, and it cannot reach the machine that would fire an alarm about it.

02 — Attestation data flow

What actually crosses the wire, in order.

One quote cycle, initiated by the verifier on its own schedule (hourly by default) or on demand via etminan-verifier check.

etminan-verifier etminan-agent 1 · QuoteRequest{ nonce } — fresh random, over mTLS 2 · sweep watched paths 3 · TPM2_Quote(nonce) 4 · read IMA log delta 5 · QuoteResponse{ attest, signature, AK pub, log delta, file metadata } 6 · verify RSA-SSA signature 7 · replay log → PCR10 8 · compare vs signed digest Genuine → diff against baseline Mismatch / silence → finding, alarm fires
Steps 2–4 never shell out to another process — a subprocess exec is itself a new IMA-measured event, and interleaving one with the quote path would corrupt exactly the evidence being collected.

The prefix-matching replay (step 7–8) tolerates a log that grew slightly between the quote being taken and the log being read — background activity on a busy host, not tampering. Only the byte range that actually reproduces the signed digest is trusted; anything appended after that point reappears, correctly, in the next cycle's delta.

03 — Baseline & approval workflow

Drift is a candidate, never an event.

Nothing becomes trusted without an explicit, signed, reasoned operator action — including the very first baseline for a newly enrolled host.

New measurement (host, path, sha256) matches baseline? yes Unchanged no matches exclusion? yes Excluded logged, never queued no Pending review plain-language verdict operator Signed approve --reason → joins baseline + audit log approve Signed reject --reason → incident, alarm fires reject
The --reason text is folded into the signed payload itself — it's tamper-evident, not a comment stored alongside an unrelated signature.

A first-time enrollment's initial measurements go through the same pipeline — they land as pending candidates for a bulk, single signed approve, never auto-trusted. Every approve/reject/exclude action, plus enroll and assign-profile, is recorded in a self-contained, hash-chained audit log inside the baseline store: each entry's hash covers the previous entry's hash, so altering, deleting, or reordering any past row breaks every hash after it. baseline verify-signatures re-checks the whole chain plus every operator signature on demand.

04 — Components

Three crates, one workspace.

etminan-common is the only code shared by both binaries — deliberately small, so the trust-sensitive parts of each binary stay easy to audit independently.

agent/

etminan-agent

Listens for mTLS-authenticated requests from its verifier. Sweeps, quotes, relays — never judges.

  • quote.rs — fixed AK template, TPM2_Quote over PCR 10.
  • sweep.rs / watch.rs — per-cycle + fanotify-triggered read sweep.
  • profiles.rs — watched-path resolution: env override → verifier-assigned profile → local named profile, backed by a centralized, verifier-pushed profile registry rather than a hardcoded default per deployment.
  • package_lookup.rs — pluggable dpkg/rpm ownership checks, own connection.
verifier/

etminan-verifier

No TPM, no inbound listener, and — as of tpm_wire.rs — no TPM library dependency at all in the shipped binary. Polls, verifies, diffs, and is the only place an alarm can fire from.

  • tpm_wire.rs — hand-rolled TPMS_ATTEST/TPMT_PUBLIC marshaling, replacing tss-esapi as a build dependency (kept dev-only, for differential testing against the real library) — restores clean musl cross-compilation.
  • verify.rs — offline signature check + PCR replay, pure math.
  • baseline.rs — SQLite: approved / pending / rejected / excluded, WAL mode.
  • audit_log.rs — hash-chained, tamper-evident action log.
  • plugin_exec.rs — shared verify-then-exec security core for every external plugin category.
  • change_source.rs / notify.rs — the Etminan Plugin API's two categories: ticket-system correlation and alarm/notification channels.
common/

etminan-common

Shared by both binaries — nothing in here trusts the process it's linked into.

  • tls.rs — mTLS: pinned-fingerprint and TOFU verifiers over rustls, no CA chain.
  • protocol.rs — length-prefixed, size-capped JSON wire format.
  • ima_log.rs — IMA log parsing + PCR-extend replay math.
05 — Security properties

What holds even if the monitored host's root is fully compromised.

Transport
Mutual TLS, pinned by certificate fingerprint — no CA chain, no plaintext fallback in either direction. The one exception is enroll's single trust-on-first-use moment, confirmed out-of-band by a human, same model as an SSH host key.
Attestation identity
A fixed AK creation template via TPM2_CreatePrimary — deterministic from the TPM's own hierarchy seed, so no persistent key storage is needed and the derived key never changes across reboots.
Operator accountability
Every approve / reject / exclude create / exclude revoke / assign-profile requires an explicit Ed25519 --key and --reason, both folded into the signed payload — a stored reason string with no signature over it would not be tamper-evident.
Audit trail
Self-contained, hash-chained log inside baseline.db — no external tool, key, or network dependency required just to record that something happened.
Plugin execution
Every external plugin — change-source or notify — runs through one shared check in plugin_exec.rs, never from a directory scan. Before every single execution — never cached — the verifier confirms the plugin is listed by name in an operator-maintained allowlist, is root-owned and not group/world-writable, and hashes to the pinned SHA-256 on file, then executes that exact already-verified file descriptor via /proc/self/fd/<n> rather than re-resolving the path a second time. A broken plugin is never a silent failure — it surfaces as an ordinary warning-severity finding through the same path as any other.
06 — Extensibility

Two different plugin shapes, chosen deliberately per trust level.

Not every extension point should have the same cost to add — but every extension point should be honest about what it costs to add safely.

In-process trait — PackageChecker

Package-ownership checks run inside etminan-agent itself. Adding apk/pacman support is a new Rust impl plus one line in a registry — no new trust boundary, since the agent already runs local, read-only queries against package-manager state it already has access to.

External plugin process — the Etminan Plugin API

Ticket-system correlation and alarm/notification channels are both a genuinely higher trust decision — a plugin's output shapes what an operator believes, or where an alert goes. Both are external, allowlisted, hash-pinned executables (any language) under one shared contract (see the Plugin API doc): adding Request Tracker, a ServiceNow-style system, or a new alert channel needs zero Rust code and no verifier rebuild — just a script matching its category's input/output shape (argv for change-source, JSON over stdin for notify) and an allowlist entry with its pinned hash. One security core, plugin_exec.rs, backs every category.

07 — Status

What's proven, what's next.

This is the condensed version of the full narrative validation record, including the real-TPM/real-host Phase 0 findings.

AreaStateDetail
Core attestation loop validated Signature, nonce, and PCR-replay checks proven against a real TPM and a real host across 30+ consecutive requests; tampered/replayed/unenrolled cases all correctly rejected.
Transport shipped Mandatory mutual TLS, pinned by fingerprint, live on every agent↔verifier connection.
Watched-path profiles shipped Named profiles plus a centralized, verifier-pushed profile registry — no hardcoded default for any deployment.
Change-source correlation shipped External plugin-process model; Request Tracker ships as the reference plugin.
Security hardening pass shipped A project-wide code audit closed a pre-auth denial-of-service in the agent's connection handling and added real test coverage to every previously-untested security-critical module.
Alarm channels shipped Email + the hash-chained audit log, plus PagerDuty/Slack/generic-webhook via the Etminan Plugin API; a broken plugin is checked every cycle rather than failing silently.
verifier cross-compilation shipped tpm_wire.rs replaced tss-esapi as a build dependency — the verifier now cross-compiles cleanly to a static musl binary, with the real library kept dev-only for differential testing.
mTLS key rotation shipped rotate-tls re-pins a host's TLS cert fingerprint after agent re-keying, without a full re-enrollment, as long as the AK fingerprint still matches.
Production pilot not started Not yet run in parallel with a real host fleet through a full patch cycle.