Skip to content

fix(tee): read TDX MRTD and REPORTDATA where the ABI puts them (#371) - #527

Merged
imran-siddique merged 1 commit into
mainfrom
agent/tdx-report-binding-371
Aug 18, 2026
Merged

fix(tee): read TDX MRTD and REPORTDATA where the ABI puts them (#371)#527
imran-siddique merged 1 commit into
mainfrom
agent/tdx-report-binding-371

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

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 is REPORTTYPE(4), RESERVED(12), CPUSVN(16), TEE_TCB_INFO_HASH(48), TEE_INFO_HASH(48), REPORTDATA(64) — REPORTDATA is at 0x80, and 0x08 is 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_data as verified. The confirmation-key binding was neither checked in the right place nor enforced where it was checked. Now read at 0x80, 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. 0x90 is inside REPORTMACSTRUCT.report_data — the 64 bytes the guest supplies. cmcp_runtime/tee/tdx.py writes 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_nonce states 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, 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 so a layout edit fails there rather than inside an attestation. It imports nothing but ctypes, so neither package drags in the other.

TDX was not gated on its quote signature

Found while checking what the offsets feed. LIMITATIONS.md says a claim whose report signature or 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: 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 exactly as SNP gates on vcek_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.md is 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-0x80 case in isolation, short-nonce zero padding, and nonce invariance. tests/unit 1121 passed, 9 skipped. ruff, mypy, bandit clean. tests/conformance/test_gateway_conformance.py::test_mcp_initialize fails identically with and without this change (pre-existing on main).

@imran-siddique for security-reviewer and maintainer review — this touches src/cmcp_verify/ and src/cmcp_runtime/tee/, both security-sensitive paths under CODEOWNERS, and I have not self-approved.

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>
@imran-siddique imran-siddique added the cosai-ws4 Named in the CoSAI WS4 Phase 1 review (ws4 #149) as a review target label Aug 18, 2026
@imran-siddique imran-siddique self-assigned this Aug 18, 2026
@imran-siddique
imran-siddique enabled auto-merge (squash) August 18, 2026 05:00

@zohebk8s zohebk8s left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_data has no home in either model. It reaches the claim as trace.runtime.nonce, base64url, written by _build_runtime as b64url(bytes.fromhex(report.report_data)). The branch has to decode that.
  • raw_quote has no field in AttestationEvidence, also extra="forbid". So verify_tdx_quote is unreachable from a claim, dcap_quote_signature is always unverified, and the new gate leaves TDX permanently PARTIALLY_VERIFIED. Correct posture with no quote transport, but not yet the SNP counterpart the PR body describes: cert_chain is 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 whose except Exception sets raw_evidence_parse_error. A malformed report_data is 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_data can land in verified_fields twice: once from the TDREPORT check, again via result.verified_fields.extend(q.verified_fields) when a quote is supplied. Improbable before, since the 0x08 read 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 -O removes them. Verified the offsets still compute correctly without them, so no impact beyond losing the guard.
  • Stray extra blank line after _MRTD_END in cmcp_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.

@imran-siddique
imran-siddique merged commit 138c85a into main Aug 18, 2026
13 checks passed
@imran-siddique
imran-siddique deleted the agent/tdx-report-binding-371 branch August 18, 2026 15:27
imran-siddique added a commit that referenced this pull request Aug 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cosai-ws4 Named in the CoSAI WS4 Phase 1 review (ws4 #149) as a review target

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: make cnf.jwk<->report_data binding fatal for SEV-SNP/TDX; fix TDX report_data offset

2 participants