Skip to content

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 key etminan-verifierd signs 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.

/etc/ssh/sshd_config.d/10-hardening.conf
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:

/etc/ssh/sshd_config.d/20-operator-forcecommand.conf
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:

/etc/fstab
tmpfs   /tmp   tmpfs   defaults,noexec,nosuid,nodev,size=512M   0   0

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:

/etc/systemd/system/etminan-verifier.service.d/10-sandboxing.conf
[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:

/etc/nftables.conf
#!/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 0440 by kernel design; no group membership grants read access. The unit grants exactly AmbientCapabilities=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_FINGERPRINT so 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.service unit 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.

See also