Skip to content

fix(tdx): parse the nested QE certification data; validate on real hardware - #420

Merged
imran-siddique merged 4 commits into
mainfrom
fix/tdx-nested-qe-certification-data
Jul 28, 2026
Merged

fix(tdx): parse the nested QE certification data; validate on real hardware#420
imran-siddique merged 4 commits into
mainfrom
fix/tdx-nested-qe-certification-data

Conversation

@imran-siddique

Copy link
Copy Markdown
Contributor

What

The TDX verifier rejected every genuine DCAP v4 quote.

Real quotes nest the Quoting Enclave material: the bytes after the attestation key are a certification-data header of type 6 (QE_REPORT_CERTIFICATION_DATA) wrapping the QE report, its PCK signature, the auth data and the type-5 PCK chain. parse_td_quote read the QE report directly after the attestation key, six bytes early, so a real GCP C3 quote failed with attestation_key_not_bound_to_qe.

The synthetic fixture emitted the same flat layout, so CI validated the defect. Failure was closed, so this is a false negative rather than an unsound accept, but the TDX path had never worked against real evidence.

How it surfaced

Running the verifiers against the SEV-SNP and TDX captures that already existed but had never been pointed at cMCP. SEV-SNP passed unchanged; TDX did not.

Now validated against real hardware

Platform Evidence Result
AMD SEV-SNP Azure DCasv5 CVM report, VCEK + AMD ASK/ARK chain Verified: paravisor REPORT_DATA binding, chain to ARK-Milan, ECDSA-P384 report signature
Intel TDX GCP C3 DCAP v4 quote, non-paravisor Verified: quote signature, QE binding, PCK signature, chain to the pinned Intel SGX Root CA

Both env-gated (CMCP_AZURE_FIXTURE_DIR, CMCP_TDX_FIXTURE_DIR); the evidence embeds per-CPU identifiers and cannot be committed. test_real_tdx_quote_agrees_with_agent_manifest pins cMCP's verdict to the shared verifier so the two cannot drift apart silently again.

Doc scoping

docs/testing/hardware-validation.md records what has been verified against real silicon and what has not, and STATUS/ROADMAP link to it instead of restating. TPM stays marked synthetic-only. The TEE.fail physical-adversary bound is stated on the page.

Test plan

  • pytest tests/ — 894 passed, 5 skipped
  • CMCP_TDX_FIXTURE_DIR=<capture> pytest tests/unit/test_tdx_quote_verify.py — 7 passed
  • CMCP_AZURE_FIXTURE_DIR=<capture> pytest tests/unit/test_azure_cvm_verify.py — 8 passed

🤖 Generated with Claude Code

imran-siddique and others added 3 commits July 27, 2026 14:56
…rdware

The TDX verifier rejected every genuine DCAP v4 quote. Real quotes nest the
Quoting Enclave material: the bytes after the attestation key are a
certification-data header of type 6 (QE_REPORT_CERTIFICATION_DATA) wrapping the
QE report, its PCK signature, the auth data and the type-5 PCK chain.
parse_td_quote read the QE report directly after the attestation key, six bytes
early, so a real GCP C3 quote failed with attestation_key_not_bound_to_qe.

The synthetic fixture emitted the same flat layout, so CI validated the defect.
Failure was closed, so this is a false negative rather than an unsound accept,
but the TDX path had never worked against real evidence.

Found by running the verifiers against the SEV-SNP and TDX captures that were
already sitting unused. Both now verify end to end, and the runs are recorded in
docs/testing/hardware-validation.md rather than asserted in prose. STATUS and
ROADMAP say which platforms have been validated against real evidence and which
have not; TPM has not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…PM quote

Captured a genuine TPM 2.0 quote from an Azure Trusted Launch VM (vTPM, secure
boot, PCRs 0-7 under a fresh nonce) and ran it through the verifier. It was
rejected: the parser required the outer TPM2B_ATTEST two-byte size prefix, but
tpm2_quote -m writes a bare TPMS_ATTEST, so every quote produced by the standard
tooling failed with TPM2B_ATTEST size field invalid. Both framings are now
accepted, told apart by the magic constant.

With that fixed, the real quote exercises the parse, the PCR digest against the
measurement field, and the qualifying-data binding, and a different nonce
correctly leaves qualifying_data unverified. Env-gated on CMCP_TPM_FIXTURE_DIR;
the capture is not committed.

Scope is unchanged: the AK signature and the EK/AK chain are out of Phase 1 and
stay in unverified_fields. docs/testing/hardware-validation.md records what this
run does and does not establish, including that Azure's pre-provisioned AK cert
covers a different key than an in-guest AK and carries no AIA extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SEV-SNP and TDX runs so far appraised stored evidence. This records a run of
the collector and verifier inside a real Azure SEV-SNP confidential VM: provider
detection true, 13,004 bytes of live evidence under a caller nonce, and
verify_azure_cvm_measurement returning verified with no unverified fields
(quote nonce, AK binding, runtime-data binding, measurement, VCEK chain to
ARK-Milan, report signature). A wrong nonce was rejected.

Two deployment facts came out of it and are documented rather than left for the
next person to rediscover: the gateway user must be in the tss group or the
provider fails with a TCTI load error that looks like broken hardware, and
trusted_ark_pem wants the ARK alone, not AMD KDS's ASK+ARK bundle.

Scope is stated too: this is attestation in situ, not a gateway serving MCP
traffic from inside the enclave with a boot-measured policy bundle. That stays
on the not-yet-validated list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imran-siddique
imran-siddique force-pushed the fix/tdx-nested-qe-certification-data branch from 594410b to 325da21 Compare July 27, 2026 21:57
Ruff wants third-party imports in their own block after stdlib. Caught by CI on
the rebase, not locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed the TDX parse against the shared implementation in agent-manifest, which was written from the same GCP C3 capture. The parsing logic is correct: identical offsets (cert_type/cert_size at 128 via a 6-byte <HI read, cert_data from 134, the 448+6 minimum), identical type checks for 6 then 5, and the QE report is de-nested rather than read six bytes early. Two independent derivations from the same hardware agreeing is good evidence.

Three notes.

1. The comment says the opposite of what the code does. The new block header states:

The signature-section parse is therefore delegated to agent-manifest, which was written against that capture, rather than duplicated here.

but parse_td_quote() below it implements the parse inline. Whichever way you resolve it, the two should agree.

As of about twenty minutes ago that comment can be made true: agent-manifest 0.7.0 is on PyPI and exports parse_tdx_quote_signature() and TdxQuoteSignature (signed_body, quote_signature, attestation_key, qe_report, qe_report_signature, qe_auth_data, pck_chain_pem), which is exactly what _ParsedQuote carries. That was released specifically so this layout has one copy. Delegating would delete most of this diff, though doing it in this PR would invalidate the hardware validation you just ran, so a follow-up may be the calmer sequencing.

2. Worth knowing for context: ca2a hit and fixed this same bug independently today (agentrust-io/ca2a#61), with its own copy of the nested parse. So the layout has now been derived correctly three times in 24 hours, which is the argument for consolidating on one parser rather than three.

3. Minor, and it applies to the shared parser equally: cert_data = sig_data[134:134 + cert_size] silently truncates when cert_size overruns the buffer. The following length check catches a grossly short slice, but a cert_size that overstates by less than the slack passes, and the parser then reads a QE report that is partly whatever followed. Every read stays in bounds and the signature check downstream would fail, so this is robustness rather than a hole, but a fail-closed parser could assert 134 + cert_size <= len(sig_data) and reject rather than silently shorten. I will make the same change on the agent-manifest side if you want it.

Not approving only because of note 1; the code itself looks right to me.

@imran-siddique
imran-siddique merged commit eb5a0cf into main Jul 28, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant