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.
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.
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.
One quote cycle, initiated by the verifier on its own schedule (hourly by default) or on
demand via etminan-verifier op check.
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.
Nothing becomes trusted without an explicit, signed, reasoned operator action — including the very first baseline for a newly enrolled host.
--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.
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.
Listens for mTLS-authenticated requests from its verifier. Sweeps, quotes, relays — never judges.
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.
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.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.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.
SO_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.etminan-verifier op <cmd>,
and every allow and deny lands on the hash-chained audit log.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.
Shared across the three binaries — nothing in here trusts the process it's linked into.
rustls, no CA chain.op enroll's single
trust-on-first-use moment, confirmed out-of-band by a human, same model as an SSH host
key.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.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.--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.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.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.baseline.db — no
external tool, key, or network dependency required just to record that something
happened.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.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.
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.
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.
This is the condensed version of the full narrative validation record, including the real-TPM/real-host Phase 0 findings.
| Area | State | Detail |
|---|---|---|
| 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. |