Hardening the deployment¶
Hardening both hosts Etminan runs on — but with a clear priority: the verifier is the one host that actually holds the trust, and it deserves the most attention.
Etminan's threat model is explicit that a monitored host can be fully compromised and the design still holds. The verifier is different: it holds the approved baseline, the daemon signing key, and the tamper-evident audit log. A compromised verifier can suppress alarms exactly as effectively as the self-checking model Etminan replaces. This chapter hardens that host specifically, then covers the agent side.
Why the verifier is special¶
What actually lives on the verifier host is small and catastrophic:
baseline.db— every approved measurement, the pending-review queue, the UID→identity registry, and the hash-chained audit log.daemon-signing.key— the one Ed25519 keyetminan-verifierdsigns every operator action with (0600).state.json— every pinned host AK/EK/TLS fingerprint and PCR-10 replay cursor.- The verifier's own mTLS identity (
tls/), whose fingerprint is pinned into every agent. - Configuration in
/etc/etminan-verifier— including plaintext integration secrets and the plugin allowlist pins.
None of that is protected by Etminan's own cryptography once someone has a real
shell on the box — the design assumes operator DB + CLI access on the verifier
is the trust boundary. Hardening this host is deliberately left to the operator,
not built into the software. One fact shapes the whole posture:
etminan-verifier never listens for inbound network connections — it only
connects out to each agent (and, optionally, to fetch the plugin catalog). The
etminan-verifierd daemon does open a listener, but it is a local Unix domain
socket (/run/etminan-verifierd/etminan-verifierd.sock), never a network port.
The operator auth boundary is the kernel, not a key file¶
The access-control model shifted from key possession to kernel identity.
Operators no longer hold Ed25519 key files; they run
etminan-verifier op <cmd>, and the
daemon authenticates each call by reading the connecting process's peer-UID
(SO_PEERCRED) off the socket and mapping it to a registered identity, role, and
scope. The peer-UID is the enforcement boundary for mapped operators: whoever
can present a given UID to that socket is that operator, so the security of the
model rests on the OS controls that decide who can run as which UID — the same
account, sudo, and login hardening below. Treat socket access and UID assignment
as the crown jewels they now are: a user who can execute code as a mapped
operator's UID can drive that operator's authority, and a user who reaches root
can read the daemon signing key off disk. Because the model is
default-deny / fail-closed, an unmapped UID is refused (and audited), not admitted.
Verifier host hardening¶
SSH: key-only, then a hardware second factor¶
Disable password and root login; require public keys.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowTcpForwarding no
AllowTcpForwarding no matters more here than usual: this host holds the daemon
signing key, so a tunnel through it is an attractive pivot. Add a real second factor with
a FIDO2/U2F-backed SSH key — the private material never leaves the token and every
use needs a touch, with no separate PAM stack:
# on your own machine, hardware key plugged in:
ssh-keygen -t ed25519-sk -O resident -O verify-required -f ~/.ssh/etminan-verifier-key
# -O verify-required forces a PIN/touch on every use, not just at generation
Warning
Test a second, already-open session before logging out after any sshd or
PAM change — a typo can lock out every future SSH session, and this box
usually has no out-of-band console at hand.
Two different second factors — don't conflate them
The FIDO2/U2F factor here is a transport / login control: it gates who can
open a shell (or reach the daemon socket) on the verifier host, at the SSH and
PAM layer. Etminan's own optional TOTP is a separate, application-level
factor inside the daemon: a per-role
op login --code requirement that gates
trust-changing op actions once you are already on the box. They are
complementary and independently configured — a hardware SSH factor does not
satisfy the daemon's TOTP policy, and vice versa. Enable the daemon's TOTP for
the admin (and, if you wish, operator) role with
op totp-policy; it is off by default.
A restricted shell for CLI-only operators¶
Every trust-changing command is signed and audited — but only if it is actually
reached through the CLI and the daemon. A full interactive shell bypasses all of
it: edit baseline.db with sqlite3, read the daemon signing key off disk, or
tamper with state.json. This matters as much under the UID-based model as before
— the daemon authorizes an operator by UID, so an account that can also open a
free shell as that UID could sidestep the daemon entirely. If an account only
needs to run etminan-verifier remotely, force every connection straight into it:
Match User etminan-operator
ForceCommand /usr/bin/etminan-verifier $SSH_ORIGINAL_COMMAND
X11Forwarding no
AllowTcpForwarding no
PermitTTY no
ssh etminan-operator@host op approve web-01 then runs exactly
etminan-verifier op approve web-01 and nothing else — the daemon still
authorizes it by the account's UID and audits the result; a bare login reaches
etminan-verifier with no arguments, which just prints help. PermitTTY no
closes the residual pseudo-terminal gap.
noexec /tmp and systemd sandboxing¶
Nothing in Etminan's operation legitimately executes a binary out of /tmp, so
mount it noexec,nosuid,nodev to close a common local-privilege-escalation and
persistence pattern for free:
The shipped etminan-verifier.service already runs as a dedicated unprivileged
user with NoNewPrivileges=true. Box it in further with a drop-in — this costs
nothing and shrinks what a compromised process can reach before an attacker ever
gets a shell:
[Service]
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/etminan-verifier
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
MemoryDenyWriteExecute=true
LockPersonality=true
ProtectSystem=strict makes the filesystem read-only except the one data
directory the verifier writes to; MemoryDenyWriteExecute=true blocks
write-then-execute in-process, on top of the filesystem-level noexec /tmp.
An independent heartbeat¶
etminan-verifier.timer firing hourly is only meaningful if something notices
when it doesn't. A compromised or broken verifier cannot be trusted to report
its own silence — the same reasoning Etminan applies to a monitored host going
quiet applies to the verifier itself. Run an independent monitor on a separate
system this box does not control, and check everything the verifier's job
depends on: the attestation cycle completes; the service is active; notification
delivery works (etminan-verifier notify-test); baseline.db and the audit log
are intact with disk headroom; the firewall still enforces default-deny; every
enrolled host is reachable; and host liveness and clock are healthy.
# on run's success path, ping a dead-man's-switch hosted off this box:
etminan-verifier run && curl -fsS --retry 3 https://<heartbeat-endpoint>/<check-id>
Configure the grace period slightly longer than the cycle interval (e.g. alert after 75 minutes) so a single slow cycle doesn't page anyone but two consecutive misses reliably does.
Disk encryption at rest¶
The entire blast radius of a stolen or decommissioned disk is baseline.db and
the daemon signing key. Full-disk LUKS at OS install time is the simplest complete
answer; encrypting just the data volume is the realistic retrofit. Encryption at
rest defends a disk read outside a running system — not a live, unlocked host,
which is what every other section here is for.
Default-deny firewall¶
Because the verifier never listens, a default-deny inbound firewall that allows only SSH is the correct posture, not a compromise:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
iif lo accept
tcp dport 22 ct state new limit rate 10/minute accept
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}
output stays permissive because the verifier must reach every agent's address;
if you can enumerate those ahead of time, restricting outbound to exactly them is
a further legitimate tightening.
Agent host hardening¶
The agent is a relay and makes no trust decision, but a few properties keep it honest and least-privileged. These are enforced by the shipped systemd unit; the agent validates each at startup and refuses to start (logging a specific error) if one is wrong.
- Never runs as root. The agent runs as an unprivileged
tss-group user and quotes through the resource-managed TPM device/dev/tpmrm0— never the raw, root-owned/dev/tpm0. - Narrowest capability for the IMA log. The IMA measurement log is
root:root 0440by kernel design; no group membership grants read access. The unit grants exactlyAmbientCapabilities=CAP_DAC_READ_SEARCH— the narrowest capability that bypasses the read-permission check — instead of running as root. - Pin the verifier. Set
ETMINAN_VERIFIER_CERT_FINGERPRINTso the agent accepts exactly one verifier. Permissive TLS (ETMINAN_ALLOW_PERMISSIVE_TLS=1) is only for the one-time enrollment bootstrap and must be dropped afterward; the agent logs a warning while it is set. - Write the IMA policy once, early. IMA accepts exactly one successful policy
write per boot; do it via the
etminan-agent-ima-policy.serviceunit at boot, not incrementally.
Before trusting a host to attest, confirm these hold from its journal and system
state: the agent started cleanly (journalctl -u etminan-agent), /dev/tpmrm0 is
present, the IMA measurement count is nonzero
(cat /sys/kernel/security/integrity/ima/runtime_measurements_count), and the
etminan-agent-ima-policy.service unit succeeded. A misconfigured TPM device, IMA
log, capability grant, mTLS identity, or verifier pinning makes the agent log a
specific error and refuse to start.
The transport is mutually authenticated by construction¶
Neither host has to be configured into using TLS — every real call site in the
common crate goes through sealed-trait write_message_tls / read_message_tls,
so "always mutual-TLS-wrapped" is compiler-enforced, not a runtime toggle you
could forget. Enrollment pins both the agent's TLS certificate fingerprint and the
verifier's, in both directions. See Mutual TLS.