fix(tee): read TDX MRTD and REPORTDATA where the ABI puts them (#371) - #527
Conversation
Issue #371 says the TDX report_data binding is advisory and read from a "best-effort/likely-wrong offset". Both halves are true, and chasing the offset turned up a second field read from the wrong place. REPORTDATA Read at 0x08. Per the Intel TDX Module ABI, REPORTMACSTRUCT is REPORTTYPE(4), RESERVED(12), CPUSVN(16), TEE_TCB_INFO_HASH(48), TEE_INFO_HASH(48), REPORTDATA(64) -- so REPORTDATA is at 0x80 and 0x08 is inside the leading RESERVED block, which a real report leaves zeroed. Every non-zero expectation therefore failed, and the failure was discarded because a mismatch only skipped recording report_data as verified. Nothing checked the confirmation-key binding in the right place, and nothing enforced it where it was checked. It is now read at 0x80 and a mismatch is fatal, the rule #390 already applies to SNP. MRTD Read at 0x90, in both the producer and the verifier. 0x90 is inside REPORTMACSTRUCT.report_data, the 64 bytes the guest supplies. The producer writes the nonce there and then hashes 48 bytes of it back out as the TD measurement. Two attestations of the same TD with different nonces produce different measurements, which is the property test added here states directly: a measurement that moves with the nonce is not a measurement. MRTD lives in TDINFO_STRUCT, at 0x210 of the report. The two offsets agreeing with each other is how this survived. Producer and verifier each carried their own copy, both wrong the same way, so every round-trip test passed. cmcp_runtime/tee/tdreport.py now holds one ctypes definition of TDREPORT_STRUCT, imported by both, with the ABI offsets asserted at import time so a layout edit fails there rather than in an attestation. It imports nothing but ctypes, so neither package pulls in the other. TDX WAS NOT GATED ON ITS QUOTE SIGNATURE Found while checking what the offsets feed. LIMITATIONS.md states that a claim whose report signature or certificate chain is unverified stays partially_verified and is never presented as hardware-backed. #390 made that true for SNP by gating on the VCEK chain. TDX had no equivalent gate: a parsed TDREPORT with a matching measurement was enough for hardware_attestation, and a TDREPORT is an unsigned buffer the host could have written. TDX now gates on dcap_quote_signature the way SNP gates on vcek_cert_chain, so the documentation and the code say the same thing. WHAT THIS DOES NOT DO No hardware run. The offsets are asserted against the published ABI and the MRTD defect is demonstrated by a property that needs no hardware to state. A TDX capture on real silicon should confirm the new offsets before the TDX row in STATUS.md is read as covering measurement provenance rather than quote verification. VERIFICATION 11 new tests covering both offsets, the fatal mismatch, short-nonce padding, and the nonce-invariance property. tests/unit 1121 passed, 9 skipped. ruff, mypy, and bandit clean. tests/conformance/test_gateway_conformance.py ::test_mcp_initialize fails identically before and after this change. Refs #371, #370, #390 Signed-off-by: Imran Siddique <imran.siddique@opaque.co> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zohebk8s
left a comment
There was a problem hiding this comment.
Checked the offsets against the Intel TDX Module ABI and the enforcement against the current claim shape. The offset work is right and the shared struct is the correct fix. One finding sits outside the diff and decides whether #371 can be closed as a gate.
The ABI half checks out
Recomputed both structures rather than trusting the comments, then ran the module:
REPORTMACSTRUCT 4 + 12 + 16 + 48 + 48 + 64 + 32 + 32 = 256 REPORTDATA at 0x80
TDINFO_STRUCT 8 + 8 + 48*4 + 192 + 48 + 64 = 512 MRTD at 0x10, so 0x210
TDREPORT_STRUCT 256 + 239 + 17 + 512 = 1024
0x90 does land inside REPORTMACSTRUCT.report_data and 0x08 inside the leading RESERVED block. test_measurement_does_not_move_with_the_nonce is a real regression test: at 0x90 the helper's report_data write covers the MRTD window, so both reports hash to something other than the expected measurement and the test fails. The MRTD half of #371 is a genuine defect and this fixes it on both sides.
The single-definition claim holds too. cmcp_runtime/__init__.py and cmcp_runtime/tee/__init__.py are docstring only, so cmcp_verify importing tdreport pulls in nothing else, and both packages ship from one wheel.
1. The fatal report_data check cannot run on a schema-valid claim
verify_trace_claim reads the TDX inputs off trace.runtime:
raw_ev = _runtime.get("raw_evidence")
report_data_hex = _runtime.get("report_data")
_quote_b64 = _runtime.get("raw_quote")RuntimeInfo is extra="forbid" with fields platform, measurement, rim_uri, nonce, firmware_version. Constructing it with any of those three raises extra_forbidden (checked against agentrust-trace 0.9.0). So on a claim that passes step 1 all three are None, verify_tdx_measurement returns no_raw_evidence at the fail-closed guard, and the new fatal branch never runs. On a claim carrying them anyway, failure is already CLAIM_MALFORMED before the platform branch.
This is #370's own conclusion applied to a branch it did not cover: AttestationEvidence says evidence cannot live under trace.runtime, "which is what kept the chain verifiers unreachable (#370)". _evidence_field exists for that and only the tpm2 branch calls it. amd-sev-snp, azure-cvm-sev-snp and intel-tdx still read trace.runtime.
Swapping in _evidence_field is not sufficient for TDX:
report_datahas no home in either model. It reaches the claim astrace.runtime.nonce, base64url, written by_build_runtimeasb64url(bytes.fromhex(report.report_data)). The branch has to decode that.raw_quotehas no field inAttestationEvidence, alsoextra="forbid". Soverify_tdx_quoteis unreachable from a claim,dcap_quote_signatureis always unverified, and the new gate leaves TDX permanentlyPARTIALLY_VERIFIED. Correct posture with no quote transport, but not yet the SNP counterpart the PR body describes:cert_chainis a field SNP evidence can actually carry.
None of this is introduced here and the verifier fixes stand on their own. But "the binding is now enforced" is true of verify_tdx_measurement and not of a claim. Wire it here, or say so in the PR body and open the follow-up before #371 closes.
2. Nothing drives this through verify_trace_claim
All 11 new tests call verify_tdx_measurement directly, as do the SNP and quote tests. That is the coverage shape that let finding 1 sit unnoticed: every layer asserted alone, no claim driven end to end. One claim-level test on the fatal path would have caught it.
3. Smaller items
bytes.fromhex(report_data_hex[...])is inside the block whoseexcept Exceptionsetsraw_evidence_parse_error. A malformedreport_datais not an evidence parse failure, and #371 is about report_data failures being invisible. Parse it before the try or give it its own reason.report_datacan land inverified_fieldstwice: once from the TDREPORT check, again viaresult.verified_fields.extend(q.verified_fields)when a quote is supplied. Improbable before, since the0x08read almost never matched; the normal path once quotes are wired.report_data_hex[:REPORT_DATA_SIZE * 2]silently truncates an over-long value. Same as SNP, so consistent, worth a look in both.- The two ABI asserts are the module's stated purpose and
python -Oremoves them. Verified the offsets still compute correctly without them, so no impact beyond losing the guard. - Stray extra blank line after
_MRTD_ENDincmcp_runtime/tee/tdx.py.
Agreed with flagging the absence of a hardware run rather than claiming the STATUS.md row got stronger. Worth noting for whoever does the capture that the measurement is sha384(MRTD) and MRTD is itself a SHA-384 digest, so it will not match the raw MRTD hex a TDX tool prints.
…529) This page exists to enforce one rule: no document describes cMCP as hardware-attested for a platform until a genuine quote from that platform has been verified end to end and the run is recorded here. The TDX row was at risk of being read wider than the run behind it. The recorded run verifies a DCAP v4 quote: attestation-key signature over header plus TD report body, the QE report binding, the PCK signature, and the PCK chain to the pinned Intel SGX Root CA. verify_tdx_measurement() also parses the 1024-byte TDREPORT_STRUCT from the TDX_CMD_GET_REPORT0 ioctl, which is a different artifact, and no real TDREPORT has ever been checked against it. That matters now because #371 found both TDREPORT field offsets wrong, MRTD read from inside REPORTMACSTRUCT.report_data and REPORTDATA read from the leading RESERVED block, and #527 corrected them against the published ABI. The correction is asserted against the ABI and against a property that needs no hardware to state, that a measurement must not move when only the nonce moves. Neither is a capture, and synthetic self-consistency is exactly what this page says is not validation. Nothing about the offsets being wrong for that long would have been visible to a reader of the table as it stood. Refs #370, #371, #527 Signed-off-by: Imran Siddique <imran.siddique@opaque.co> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #371, and closes the last TDX gap in #370. Part of the CoSAI WS4 Phase 1 review (ws4 #149) close-out — these are two of the four cmcp items the RFC told reviewers to treat as gates.
REPORTDATA
Read at
0x08. Per the Intel TDX Module ABI, REPORTMACSTRUCT isREPORTTYPE(4),RESERVED(12),CPUSVN(16),TEE_TCB_INFO_HASH(48),TEE_INFO_HASH(48),REPORTDATA(64)— REPORTDATA is at0x80, and0x08is inside the leading RESERVED block, which a real report leaves zeroed.So every non-zero expectation failed, and the failure was thrown away: a mismatch only skipped recording
report_dataas verified. The confirmation-key binding was neither checked in the right place nor enforced where it was checked. Now read at0x80, and a mismatch is fatal — the rule #390 already applies to SEV-SNP.MRTD, which is the more serious half
Read at
0x90, in both the producer and the verifier.0x90is insideREPORTMACSTRUCT.report_data— the 64 bytes the guest supplies.cmcp_runtime/tee/tdx.pywrites the nonce there, then hashes 48 bytes of it straight back out as the TD measurement.Two attestations of the same TD with different nonces therefore produce different measurements.
test_measurement_does_not_move_with_the_noncestates it as a property rather than an offset assertion: a measurement that moves with the nonce is not a measurement. MRTD lives in TDINFO_STRUCT, at0x210of the report.The two offsets agreeing with each other is how this survived. Producer and verifier each carried their own copy, both wrong the same way, so every round-trip test passed.
cmcp_runtime/tee/tdreport.pynow holds one ctypes definition of TDREPORT_STRUCT imported by both, with the ABI offsets asserted at import so a layout edit fails there rather than inside an attestation. It imports nothing butctypes, so neither package drags in the other.TDX was not gated on its quote signature
Found while checking what the offsets feed.
LIMITATIONS.mdsays a claim whose report signature or chain is unverified stayspartially_verifiedand is never presented as hardware-backed. #390 made that true for SNP by gating on the VCEK chain. TDX had no equivalent: a parsed TDREPORT with a matching measurement was enough forhardware_attestation, and a TDREPORT is an unsigned buffer the host could have written. TDX now gates ondcap_quote_signatureexactly as SNP gates onvcek_cert_chain, so the docs and the code agree.What this does not do
No hardware run. The offsets are asserted against the published ABI, and the MRTD defect is demonstrated by a property that needs no hardware to state. A TDX capture on real silicon should confirm the new offsets before the TDX row in
STATUS.mdis read as covering measurement provenance rather than quote verification. Flagging that rather than quietly claiming the row is now stronger than it is.Verification
11 new tests: both ABI offsets, the fatal mismatch, the
0x08-vs-0x80case in isolation, short-nonce zero padding, and nonce invariance.tests/unit1121 passed, 9 skipped.ruff,mypy,banditclean.tests/conformance/test_gateway_conformance.py::test_mcp_initializefails identically with and without this change (pre-existing onmain).@imran-siddique for security-reviewer and maintainer review — this touches
src/cmcp_verify/andsrc/cmcp_runtime/tee/, both security-sensitive paths under CODEOWNERS, and I have not self-approved.