Skip to content

fix(tee-attestation): record which TCB baseline decided the verdict - #3914

Merged
chefsale merged 1 commit into
masterfrom
claude/tcb-baseline-visibility
Sep 11, 2026
Merged

chefsale merged 1 commit into
masterfrom
claude/tcb-baseline-visibility

Conversation

@chefsale

Copy link
Copy Markdown
Member

[tee-attestation] record which TCB baseline decided the verdict

Description

tcb_status on its own is not interpretable. OutOfDate does not mean "this host is unpatched" — it means "this host is behind whichever TCB evaluation data the verifier happened to fetch." Intel publishes at least two concurrently: update=early runs ahead of update=standard. So two verifiers appraising the same host at the same moment can legitimately disagree, and nothing in a log tells you which happened.

That is not hypothetical for us. A node is judged twice, by different code:

verifier who runs it what it decides
this crate — dcap-qvl against Intel PCS core group admission
Intel Trust Authority the mero-tee KMS key release

On 2026-09-11 ITA reported tcbEvaluationDataNumber = 22 for a Calimero node — the early collateral, which Intel began serving on 2026-08-11. This crate requests neither update nor tcbEvaluationDataNumber (verify.rs: CollateralClient::with_default_http(INTEL_PCS_URL) then a bare fetch), so it takes whatever the PCS default yields. Nothing pins the two together, and neither side records which baseline it used.

Why that matters. The invariant these two must preserve is policy_core ⊆ policy_kms — anything core admits, the KMS must be willing to serve a key to. Today it holds only because the KMS allowlist (["uptodate", "outofdate"]) is the wider of the two. Tighten the KMS to UpToDate alone — which is the stated goal, with the exit condition already written down at DEFAULT_ALLOWED_TCB_STATUS — while core is evaluating against older collateral, and the failure mode is a node that joins its group and then silently never receives a key. No error names the cause.

This PR makes the divergence visible. It deliberately does not resolve it — see Scope below.

Changes

  • VerificationResult gains tcb_evaluation_data_number: Option<u32>, parsed from the fetched collateral and documented with the invariant above. This fits the type's existing contract, which already states it is a report carrying "the raw material a caller needs to make a policy decision," not a verdict.
  • Logged beside the status it qualifies — a bare OutOfDate in a log is not actionable without it.
  • None for mock quotes (no collateral is fetched) and for unparseable TCB info.
  • New dep serde_json on this crate. It was already in the workspace lock, so no new package enters the dependency graph — Cargo.lock gains one line, a back-reference.

Scope — what this deliberately does not do

Pinning an explicit baseline (update=early to match ITA, or standard) is the other half. I'm not doing it here because:

  • It changes which collateral is fetched, which changes admission decisions.
  • dcap-qvl 0.5.3 hardcodes qe/identity?update=standard but sends tcb?fmspc=… with no update at all, so pinning means supplying a custom HttpClient — a real change to the verification path.
  • I cannot validate it against the live Intel PCS from a sandboxed environment. Shipping an unvalidated change to attestation collateral fetching is how you brick joins fleet-wide.

It belongs in its own change, argued on the evidence this one makes available.

Test plan

cargo test -p calimero-tee-attestation --lib — 24 pass, including four new:

test pins
reads_the_evaluation_data_number_from_collateral the number is parsed from real-shaped PCS tcbInfo
distinguishes_the_early_and_standard_baselines 22 vs 20 are distinguishable — the entire point
unparseable_collateral_degrades_to_none diagnostic, never a gate: bad JSON must not fail a good attestation
does_not_accept_the_signed_envelope guards the envelope-vs-inner-object distinction

That last one is the non-obvious one. dcap-qvl stores the unwrapped tcbInfo object, not the signed {tcbInfo, signature} envelope the PCS returns (its own verify.rs:300 deserializes the same string). If a future version stored the envelope instead, this parse would silently start returning None and the divergence signal would go quiet without anything failing. The test fails loudly instead.

Full gate, run via ./scripts/check-like-ci.py --list rather than from memory:

  • cargo fmt --check — clean
  • cargo clippy --workspace --all-targets --features calimero-storage/testing -- -D warnings — clean, exit 0
  • cargo clippy -p merod -p calimero-node -p calimero-server -p calimero-tee-attestation --all-targets --features mock-attestation -- -D warnings — clean, exit 0
  • cargo test -p calimero-tee-attestation --features mock-attestation — 24 pass
  • cargo test -p merod --features mock-attestation — 105 + 2 pass

Two disclosures rather than silent gaps:

  • The two build scripts that fetch auth-frontend / webui over HTTPS fail TLS in this sandbox. I ran the gate with CALIMERO_AUTH_FRONTEND_SRC / CALIMERO_WEBUI_SRC pointed at empty local dirs — the documented escape hatch — rather than narrowing what I ran. No TLS verification was disabled.
  • cargo deny check licenses sources was not run: cargo-deny isn't installed here. The argument that it's a no-op is that serde_json is an existing workspace dependency and Cargo.lock gains only a back-reference, no new package — but that is reasoning, not a run. Worth confirming in CI.

Proof the fix works

This is not a bug fix with a failing reproduction — it adds a signal that did not exist. The "before" is that tcb_status was logged with no way to tell which baseline produced it; the "after" is the new info! line carrying both, and does_not_accept_the_signed_envelope is the regression test that keeps the signal from going quiet.

The motivating observation is real and external: ITA reported tcbEvaluationDataNumber = 22, attester_tcb_status = OutOfDate, advisories INTEL-SA-01439 / INTEL-SA-01442, attester_tcb_date = 2026-02-11 for a released Calimero node image on 2026-09-11.

Wire contract (SDK gate)

No HTTP wire DTO or route changes. VerificationResult is an internal Rust type, not a serialized DTO.

  • Regenerated wire fixtures — n/a, no DTO changed
  • Updated crates/server/endpoints.json — n/a, no routes changed
  • Linked the matching mero-js PR — n/a

Documentation update

The field carries its rationale in a doc comment, which is where a caller deciding policy will meet it. If the follow-up pins a baseline, that decision belongs in the protocol docs alongside the existing DEFAULT_ALLOWED_TCB_STATUS reasoning — this PR doesn't make that choice, so there's nothing to state yet.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Lnezo7uTBiJYPsoYPvRiW3


Generated by Claude Code

`tcb_status` alone is not interpretable. `OutOfDate` does not mean "this
host is unpatched"; it means "this host is behind whichever TCB
evaluation data the verifier happened to fetch". Intel publishes at
least two concurrently -- `update=early` runs ahead of `update=standard`
-- so two verifiers appraising the same host at the same moment can
legitimately disagree.

That is not hypothetical here. A node is judged twice by different code:
this crate (dcap-qvl against Intel PCS) decides admission, and the
mero-tee KMS (Intel Trust Authority) decides key release. On 2026-09-11
ITA reported tcbEvaluationDataNumber 22 for a Calimero node -- the
`early` collateral, which Intel began serving on 2026-08-11. This crate
requests neither `update` nor `tcbEvaluationDataNumber`, so it takes
whatever the PCS default yields. Nothing pins the two together.

The invariant those two must preserve is `policy_core` subset of
`policy_kms`: anything core admits, the KMS must be willing to serve a
key to. It currently holds only because the KMS allowlist is the wider
of the two. Tighten the KMS to `UpToDate` alone -- the stated goal --
while core evaluates against older collateral, and the failure mode is a
node that joins its group and then silently never receives a key.

Surface `tcb_evaluation_data_number` on `VerificationResult` and log it
beside the status it qualifies, so divergence is visible in logs and to
callers rather than inferred from a stuck node. It is diagnostic and
never a gate: unreadable collateral degrades to `None` rather than
failing an otherwise-good attestation, since trading a real security
check for a cosmetic one is the wrong way round.

This records the divergence; it does not yet resolve it. Pinning an
explicit baseline means changing which collateral is fetched, which
changes admission decisions, and it cannot be validated against the live
Intel PCS from a sandboxed environment. That belongs in its own change,
with the evidence this one makes available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lnezo7uTBiJYPsoYPvRiW3
@chefsale chefsale changed the title [tee-attestation] record which TCB baseline decided the verdict fix(tee-attestation): record which TCB baseline decided the verdict Sep 11, 2026

Copy link
Copy Markdown
Member Author

Windows node smoke is red, and it is not this PR's

Failing check: Windows node smoke — step Drive a real node on Windows.

All three watchdog_live tests panicked at the same line, and the whole suite finished in 0.01s:

thread 'the_node_serves_health_and_then_stops' panicked at crates\merod\tests\watchdog_live.rs:109:5:
no port was free for both TCP and UDP after 32 attempts

failures:
    the_node_keeps_running_while_its_data_directory_is_intact
    the_node_serves_health_and_then_stops
    the_node_stops_when_its_stdin_closes

test result: FAILED. 0 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

They died inside free_swarm_port's probe loop — before a node was ever started. free_swarm_port retries SWARM_PORT_PROBES (32) times, each time taking a TCP-free port and testing its UDP half; on this runner all 32 candidates failed the UDP bind.

That is the condition the helper's own doc comment already describes:

on Windows a UDP bind inside a Hyper-V / WinNAT excluded port range fails with WSAEACCES (os error 10013) although the TCP probe a line earlier succeeded. […] Excluded ranges are reserved per boot, so it is intermittent and Windows-only.

This narrows the window; it does not close it.

Why it isn't this PR's. The diff is confined to crates/tee-attestation — one Option<u32> field on VerificationResult, a serde_json parse of already-fetched collateral, and four unit tests. Nothing in it touches port selection, socket binding, merod startup, or any Windows-specific path, and the failure occurs during port probing before merod runs at all. ci-checks.yml is green on master.

No fix ported, because none exists yet. Making free_swarm_port robust against a large excluded range is a real improvement — reading WinNAT's reserved ranges instead of sampling blindly, or holding the UDP socket until handoff — but it belongs in its own change against crates/merod, not folded into an attestation PR. I'd rather leave this PR reviewable than widen it into Windows port probing.

Re-run: attempted and currently refused — 403 This workflow is already running, because the Rust job in the same run is still executing Cargo test. It is queued to fire once that run completes, and I'm keeping this PR watched until it's green rather than leaving it here.

For the record, the rest of the Rust job is green so far on this head: Build, Cargo format, Naming gate, and Cargo clippy all passed; Cargo test, the mock-attestation clippy/test pair, Cargo deny and machete are still to report.


Generated by Claude Code

@chefsale
chefsale merged commit 6da23c4 into master Sep 11, 2026
170 of 172 checks passed
@chefsale
chefsale deleted the claude/tcb-baseline-visibility branch September 11, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants