The attestation loop¶
Etminan's core is one repeating question asked of every enrolled host: does the IMA measurement log the host shows me replay, byte for byte, to the exact PCR value its TPM just signed? This chapter follows a single run/check cycle end to end, from the nonce the verifier sends to the verdict it records.
The whole loop is deliberately one-directional in trust: the agent only ever relays a TPM-signed quote and the raw log delta, and the verifier makes every decision. A fully root-compromised host cannot forge a "genuine" verdict here, because the verdict is pure offline signature-and-replay math the host never gets to perform on its own behalf.
The two entry points¶
There is one loop and two ways to drive it. Both run the identical verify::check core against each host.
| Command | What it does | When you run it |
|---|---|---|
etminan-verifier run |
Checks every enrolled host in one pass, fires the full alarm path for any finding, verifies every configured plugin, and re-verifies the audit-log hash chain. Exits non-zero if any host produced a finding. Takes no flags. | The scheduled entry point — etminan-verifier.timer calls it hourly in production. |
etminan-verifier check --host <id> --addr <ip:port> |
Checks one host interactively and prints the outcome. Exits 1 if anything failed to verify. |
On demand, when you want to look at a single host now. |
# Scheduled fleet-wide pass (what the timer runs):
etminan-verifier run
# Interactive single-host check:
etminan-verifier check --host web-01 --addr 10.0.0.5:7620
run is more than a loop over check
Each run also does three things a bare check does not: it feeds every finding to the alarm path (structured local log, the hash-chained audit log, email if configured, and every notify-plugin channel); it re-verifies every configured change-source/notify plugin (the same check as plugins verify), so a dead notification channel is discovered before it is needed, not during an incident; and it re-verifies the local audit_log hash chain (the same check as baseline verify-signatures), so a tampered or rolled-back audit trail fires an alarm on the very next cycle. Because run exits non-zero on any finding, external unit monitoring (e.g. Gatus watching etminan-verifier.service) is a second, independent failure-detection channel beyond the notification plugins themselves.
One cycle, step by step¶
For each host, the verifier opens its pinned mutual-TLS connection to the agent, sends a fresh random nonce, and asks for a quote. The agent returns a TPM-signed quote (the attest structure plus its RSA signature), the host's marshalled AK public key, and the raw IMA measurement-log delta since the verifier's last recorded log offset. Everything after that is offline math on the verifier.
sequenceDiagram
autonumber
participant T as etminan-verifier.timer
participant V as etminan-verifier (run/check)
participant A as etminan-agent (relay)
participant TPM as Host TPM 2.0
T->>V: hourly trigger
loop each enrolled host
V->>V: generate 32-byte random nonce
V->>A: Quote request (nonce) over pinned mTLS
A->>TPM: TPM2_Quote(PCR 0..10, nonce)
TPM-->>A: signed attest + signature
A->>A: read IMA log delta from last offset
A-->>V: attest + signature + AK public + raw IMA log delta
V->>V: 1. AK fingerprint == enrolled?
V->>V: 2. RSA-SSA/SHA-256 signature valid over attest?
V->>V: 3. PCR selection exactly {PCR10, SHA-256}? nonce echoes?
V->>V: 4. replay log delta, extend running PCR 10
V->>V: 5. SHA256(boot prefix ∥ PCR10) == signed pcr_digest?
V->>V: 6. each line's columns hash back to its template_hash?
alt every check passes
V->>V: verdict = Genuine — diff new measurements vs baseline
V->>V: persist new cumulative PCR10 + log offset
else any check fails
V->>V: verdict = FINDING (kind) → alarm path + audit_log
end
end
1. Pin the identity: AK fingerprint¶
The verifier hashes the presented AK public key (SHA-256) and compares it to the fingerprint pinned at enrollment. A mismatch is the verdict UnenrolledOrMismatchedAk — the host is presenting an attestation key that is not the one it enrolled with, and nothing further is trusted. (At enroll time there is no pinned fingerprint yet; that is the one moment this check is skipped — see Enrolling a host.)
2. Verify the quote signature¶
The AK public key is parsed, and the attest bytes are verified against it. Etminan supports exactly RSA-SSA over SHA-256 (sigAlg=0x0014, hashAlg=0x000B); anything else is refused. A signature that does not verify is SignatureInvalid. This is the step that makes the quote the TPM's statement rather than the host's.
3. Bind the quote to this request and this PCR¶
Two guards run before the digest is trusted:
- PCR selection must be exactly
{PCR 10, SHA-256}(extended to the measured-boot set PCR 0–10 when a boot event log is present). The signedpcr_digestmixes in only the PCR value, never the index — so without this guard, a compromised host could extend a resettable PCR (16, 23) to a fabricated value and quote that with a genuine signature. Anything else isUnexpectedPcrSelection. - Nonce echoed back in the quote's
extraDatamust equal the 32-byte nonce this cycle sent. A mismatch isNonceMismatch— a stale or replayed quote. (The verifier enforces a hard 16-byte minimum nonce floor as a misuse guard; it always sends 32 random bytes.)
4 & 5. Replay the log and recompute PCR 10 — the load-bearing step¶
This is the heart of Etminan. The verifier starts from the cumulative PCR 10 value it recorded last cycle and replays the raw IMA log delta line by line, extending its own running PCR 10 exactly as the kernel did (running = SHA256(running ∥ template_hash)) for every PCR-10 entry. After each line it asks: does SHA256(boot_prefix ∥ (10, running)) now equal the digest the TPM signed?
- If a prefix reaches the signed digest, the log the host showed you is cryptographically bound to the value the TPM signed. The human-readable measurement log and the cryptographic quote are now the same fact. Verdict:
Genuine. - If a complete delta never reaches it, the log was fabricated or is desynced —
PcrDigestMismatch. Nothing is trusted; measurements are not handed to the baseline diff. - If the delta was capped by the agent (a large backlog,
log_truncated) and no prefix matched yet, that is the expectedCatchingUpstate, not tampering — the cumulative and offset advance across the validated-so-far chunk and the signed match completes on a later cycle.
Why replay past the first match
The scan does not stop at the first matching prefix. The agent's own in-memory log cursor always advances to the end of the delta it read, regardless of how much of it this particular quote covered. If the verifier stored only the matched prefix, the trailing lines (already consumed by the agent, never re-sent) would leave a permanent cursor gap and every later replay would fail as PcrDigestMismatch. So the verifier records the matched prefix's length for diagnostics but keeps replaying to the end, keeping its cumulative and offset aligned with the agent's cursor.
6. Bind the columns, not just the PCR¶
A genuine signature and matching PCR still leave one gap: a host could keep the real (malicious) template_hash so the PCR replays correctly, while rewriting the displayed path/file_hash columns the baseline diff trusts. So for every PCR-10 entry the verifier re-hashes the displayed columns and confirms they reproduce the extended template_hash. If they don't — or if an entry is unrecomputable (unknown template, mangled column) — the verdict is TemplateHashMismatch: the signature is real but the host is concealing what actually ran. Genuine IMA violation markers (the kernel's all-0x00/0xFF ToMToU pseudo-digests) are replayed but exempted from this check, since they are not column-doctoring.
The verdicts¶
verify::check returns exactly one outcome per host. Genuine and CatchingUp advance state; every other outcome is a finding fed to the alarm path and recorded in the audit log.
| Verdict | Meaning | Trusted? |
|---|---|---|
Genuine |
Signature, nonce, PCR selection, replay, and column binding all pass | Yes — measurements go to the baseline diff |
CatchingUp |
Valid so far, but the delta was capped below the quote's coverage (backlog) | Provisionally — advances state, alarms on a later real mismatch |
UnenrolledOrMismatchedAk |
Presented AK ≠ the fingerprint pinned at enrollment | No — finding |
SignatureInvalid |
RSA-SSA/SHA-256 signature did not verify over the attest bytes | No — finding |
NonceMismatch |
Quote's extraData ≠ the nonce we sent (stale/replayed) |
No — finding |
UnexpectedPcrSelection |
Quote covers a PCR/bank other than {PCR 10, SHA-256} |
No — finding |
PcrDigestMismatch |
A complete delta never replayed to the signed PCR — fabricated log/desync | No — finding |
TemplateHashMismatch |
Genuine PCR, but displayed columns don't hash back to the extended template_hash |
No — finding, measurements discarded |
What a Genuine host does next¶
Only after Genuine does the verifier diff the freshly measured, cryptographically-bound files against the approved baseline:
- Measurements whose hash already matches the approved baseline are silent (counted as unchanged).
- New or changed measurements go to
pending_review— never auto-approved. You act on them in Baseline review. - Any pending item older than the review SLA (24h by default,
ETMINAN_PENDING_REVIEW_SLA_HOURSto override) is escalated to a warning-severity finding, so pending review is never a silent, indefinite state. - On a package-capable host, each pending path is best-effort correlated against installed dpkg/rpm packages — on a separate connection from quote-taking, since a package lookup is itself an IMA-measured subprocess.
Finally, on Genuine (or CatchingUp) the verifier persists the new cumulative PCR 10 and log offset, so the next cycle resumes exactly where this one ended.
Where findings go¶
Every finding a run produces is written to the hash-chained audit log and pushed out the alarm path — the only place a finding leaves the box. Configure that path in Notifications and SIEM output. Because a broken verifier looks exactly like "all healthy", pair run with an independent external heartbeat — see Hardening.
Related¶
- Enrolling a host — establishing the AK fingerprint and first baseline this loop checks against.
- Baseline review — acting on the pending items a
Genuinecycle produces.- Troubleshooting — reading aPcrDigestMismatchorAK fingerprint mismatchfinding.