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

Three 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 the hourly check — no TPM needed, offline signature math etminan-verifierd holds the only signing key — every operator action, audited 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 TRUST BOUNDARY — the verifier does not check itself signed integrity summary + exported evidence bundle WITNESS (third machine) etminan-witness no daemon, no database, no key, no TPM
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 same reasoning is applied once more to the verifier itself: what it says about its own integrity is checked by etminan-witness on a third machine, because evidence checked only where it was produced has not been checked.

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 op enroll runs — a single trust-on-first-use ceremony, confirmed out-of-band by a human, exactly like an SSH host key. Enroll also runs an EK credential-activation challenge that proves the AK resides in the same TPM as the host's Endorsement Key and refuses a software-generated key, so the human step confirms which machine, not whether a real TPM is involved. 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 op 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.

Reproducing the signed PCR only proves the sequence of template hashes the kernel extended — a compromised host could keep a malicious file's real template hash (needed to match the PCR) while rewriting that log line's visible path and file-hash columns to benign values. So for every matched line the verifier also recomputes the template hash from those displayed columns and requires it to equal the one that was extended; a mismatch means the columns were doctored and the host is not trusted. The agent sends the IMA log as raw kernel bytes (not a lossily text-converted copy) precisely so this re-hash is byte-exact even for a filename that isn't valid UTF-8 — otherwise a deliberately invalid-UTF-8 path could dodge the binding check.

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 via etminan-verifierd op approve daemon signs on the operator's behalf → joins baseline + audit log approve op reject daemon signs on the operator's behalf → 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 op approve, never auto-trusted. Every approve/reject/exclude action, plus op enroll and op 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. op verify-signatures re-checks the whole chain plus every operator signature on demand.

04 — Components

Three crates, four binaries, one workspace.

Five things ship, on three machines. The agent on the monitored host; the checking job and the RBAC daemon on the verifier; the witness on a third machine that checks what the verifier says about itself. etminan-common is the only code shared by all four binaries — deliberately small, so the trust-sensitive parts of each stay easy to audit independently. Three crates rather than four, because verifierd and witness are binaries of the verifier crate, not crates of their own.

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.
  • plugin_catalog.rs — the certified plugin catalog (plugins list/install/update): a signed manifest verified against a dedicated, single-purpose Ed25519 key (never GPG, never a reused signing key) before a single entry in it is trusted; extends plugin_exec.rs's model rather than replacing it.
verifier/src/bin/

etminan-verifierd

The RBAC daemon. A long-running, privilege-separated Unix-socket server, and the only process that touches verifier state or the signing key. Runs on the verifier beside the checking job, as its own systemd unit.

  • Authenticates by kernel UIDSO_PEERCRED, unforgeable for a non-root peer, against a default-deny role and host-scope registry. The socket itself gates nothing: a role can come from a directory group, and Unix groups do not nest.
  • Holds the one signing key — operators hold none. An operator with a shell on the verifier still cannot sign a decision the daemon would refuse.
  • Every action arrives as etminan-verifier op <cmd>, and every allow and deny lands on the hash-chained audit log.
  • Roles may come from a directory — an Active Directory or LDAP group, off until a group is mapped, and never consulted by an installation that maps none.
verifier/src/bin/

etminan-witness

The receiving side, and its own package, on a third machine. Evidence checked only where it was produced has not been checked: a verifier that has been taken over keeps signing perfectly well, and will hand you a fresh, valid summary over a frozen audit head.

  • Checks two things — a signed integrity summary, or an exported evidence bundle, against the published key.
  • Carries nothing it does not need — no daemon, no database, no signing key, no TPM, no service user. That is why it is a separate package and not a flag: nobody installs a second verifier on a mail host to read one line of JSON.
  • Raises an alarm rather than failing quietly — a head that went backwards, a summary seen twice, or one signed by somebody else is a finding.
common/

etminan-common

Shared across the three 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 op 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. The verifier enforces this template's exact shape on every quote it accepts: a 2048-bit RSA key with a 256-byte modulus, or the quote is refused outright — closing a gap where a host could otherwise submit a differently-shaped key at enrollment time, the one moment this project's own threat model treats the host as possibly already compromised.
TPM residency proof (enrollment)
Before pinning anything, op enroll runs an EK credential-activation challenge — TPM2_MakeCredential (pure software on the verifier) sealing a random secret to the host's Endorsement Key and the quote's AK, recovered only by the host's TPM2_ActivateCredential. Enrollment is refused unless the secret comes back, proving the AK actually resides in the same TPM as the EK. A software-generated key of the right shape can pass the template check above but can't recover the challenge, so it can't enroll.
Genuine-hardware proof (optional, enrollment)
Residency alone proves the AK and EK share a TPM, not that that TPM is real silicon rather than a software/emulated one under a compromised hypervisor. Enrolling with --ek-roots <dir> closes that gap for discrete TPMs: the host reads its manufacturer EK certificate from TPM NV and sends it, and the verifier requires the cert's key to equal the activated EK and the cert to chain, by RSA signature, to a self-signed manufacturer root the operator placed in the directory (validity windows checked at every hop). With the flag a host with no EK cert (the vTPM norm) is refused; without it, enrollment is residency-only and the out-of-band human confirmation is what covers which host. Pure Rust (x509-cert + the existing rsa crate); RSA CAs only; no CRL/OCSP yet.
Operator accountability
Every op approve / op reject / op exclude / op assign-profile / op rotate goes through the etminan-verifierd RBAC daemon: the operator runs etminan-verifier op <cmd>, the daemon authenticates them by their kernel UID (SO_PEERCRED, unforgeable for non-root), and the daemon — the sole holder of the signing key — signs the action into the tamper-evident audit log. Operators hold no key files, and the request path carries no credential at all — no password, no PAM, no external identity provider to steal, phish, or misconfigure. The operator's existing Unix login is the credential.
Authorization (who may act) — default-deny RBAC
The daemon maps each operator's Unix UID, in its identity registry, to exactly one role and a scope. The matrix is default-deny: a UID that is unmapped or revoked is refused every trust-changing action. Roles: admin (manages the identity registry; always unscoped), operator (day-to-day actions, confined to an assigned host-group), and viewer (read-only, signs nothing). The genesis admin is bootstrapped once as root (op bootstrap); identities are managed with op identity add|list|revoke. Every allow and every deny is written to the hash-chained audit log. RBAC is part of the Standard build and on by default.
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. For a plugin Etminan itself publishes, plugins install/update (plugin_catalog.rs) collapses the manual allowlist steps into one command without lowering the bar — the same hash-pinned check still runs at execution time either way; the catalog only changes how a plugin and its pinned hash arrive.

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; every scheduled run re-checks each plugin's config and re-verifies the audit-log hash chain, so a broken channel or a tampered/rolled-back audit trail (a critical audit-chain-invalid finding) surfaces on the next 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 op 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.
Certified plugin catalog client shipped, catalog not published plugins list/install/update and the Ed25519 signature-verification machinery are real and tested; no signed plugins-catalog.json is published anywhere yet, so these commands correctly error on the fetch until that pipeline exists.
Production pilot not started Not yet run in parallel with a real host fleet through a full patch cycle.