fix(tdx): parse the nested QE certification data; validate on real hardware - #420
Conversation
…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>
594410b to
325da21
Compare
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
left a comment
There was a problem hiding this comment.
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.
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_quoteread the QE report directly after the attestation key, six bytes early, so a real GCP C3 quote failed withattestation_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
REPORT_DATAbinding, chain to ARK-Milan, ECDSA-P384 report signatureBoth 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_manifestpins cMCP's verdict to the shared verifier so the two cannot drift apart silently again.Doc scoping
docs/testing/hardware-validation.mdrecords 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 skippedCMCP_TDX_FIXTURE_DIR=<capture> pytest tests/unit/test_tdx_quote_verify.py— 7 passedCMCP_AZURE_FIXTURE_DIR=<capture> pytest tests/unit/test_azure_cvm_verify.py— 8 passed🤖 Generated with Claude Code