diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de4bce..4b6d6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Raised the `agent-manifest` floor to `>=0.6.1` and made an unappraisable manifest fail closed with a diagnostic message. `verify_agent_manifest_binding` runs the SDK verifier over a peer-supplied manifest, and before 0.6.1 a manifest declaring `ML-DSA-65` or `hybrid-Ed25519-ML-DSA-65` crashed the SDK with an uncaught `RuntimeError` on any install without the optional `[pq]` extra, so this path answered a crash instead of a rejection. The SDK now returns `UNVERIFIABLE`, which cMCP already rejects; the `UNVERIFIABLE` message now distinguishes "could not be verified" (missing trusted key or unavailable algorithm) from "verification failed" (a bad signature) and surfaces the verifier's own reason, because those call for different operator responses. +- **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 test fixture emitted the same flat layout, so CI validated the defect. Failure was closed, so this was a false negative rather than an unsound accept, but the TDX path had never worked against real evidence. The parser now handles the nested layout, the fixture builds the real shape, and a new test rejects the flat layout outright. + +### Added + +- **Real-hardware validation for SEV-SNP and TDX**, recorded in [`docs/testing/hardware-validation.md`](docs/testing/hardware-validation.md). `cmcp_verify` now verifies a genuine Azure CVM SEV-SNP report (VCEK chain to the AMD ARK-Milan root, ECDSA-P384 report signature, paravisor `REPORT_DATA` binding) and a genuine GCP C3 Intel TDX DCAP v4 quote (PCK chain to the pinned Intel SGX Root CA, QE binding, quote signature) end to end. Both are env-gated (`CMCP_AZURE_FIXTURE_DIR`, `CMCP_TDX_FIXTURE_DIR`) because 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. STATUS and ROADMAP now say which platforms have been validated against real evidence and which have not (TPM has not). ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index f319213..e98ff6a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,17 +11,19 @@ Scope: Minimal viable trust layer for MCP servers, sufficient for early adopters ## v0.2: Released (June 2026) -Provider-specific attestation verification (report parsing plus certificate-chain -verification, validated against real vendor roots; report-signature paths validated with -synthetic vectors): -- TPM2 quote verification -- AMD SEV-SNP attestation report parsing and verification -- Intel TDX attestation report parsing and verification - -> Report *generation* requires the corresponding TEE hardware. Until a backend verifies a -> real quote end to end against a golden measurement on a confidential VM, these verifiers -> should not be described as fully hardware-attested. This is the same status tracked for -> the shared verifier code in the sibling [ca2a](https://github.com/agentrust-io/ca2a) repo. +Provider-specific attestation verification (report parsing, certificate-chain verification +against real vendor roots, and report-signature verification): +- TPM2 quote verification (synthetic vectors only) +- AMD SEV-SNP attestation report parsing and verification (**validated against a real Azure + CVM report**) +- Intel TDX attestation report parsing and verification (**validated against a real GCP C3 + DCAP v4 quote**) + +> Report *generation* requires the corresponding TEE hardware, so these runs validate the +> verifier against genuine evidence rather than cMCP running inside the TEE. Each run is +> recorded in [`docs/testing/hardware-validation.md`](docs/testing/hardware-validation.md). +> TPM has no real-hardware run yet. The shared verifier code in the sibling +> [ca2a](https://github.com/agentrust-io/ca2a) repo tracks the same status. Server integration: - Session-scoped TRACE Claim emission wired into `server.py` request lifecycle diff --git a/STATUS.md b/STATUS.md index d9cbad2..1bc5adf 100644 --- a/STATUS.md +++ b/STATUS.md @@ -24,7 +24,8 @@ picture is stated once. Developer Preview: interfaces may change before v1.0. | `GatewayClaim` (TRACE Claim) generation + signing | Shipped | Normative schema: [`schemas/trace-claim.schema.json`](schemas/trace-claim.schema.json). | | Offline verification (`cmcp_verify`) | Shipped | No operator trust required when the verifier independently checks the attestation report. | | Agent Manifest identity binding | Shipped | Optional; trust in the issuer key is an out-of-band PKI concern. | -| Attestation verifiers: `tpm`, `sev-snp`, `tdx` | Partial | Report parsing + certificate-chain verification against real vendor roots; report-signature paths validated with synthetic vectors. End-to-end validation against a real hardware quote on a confidential VM is pending — do not describe as fully hardware-attested until then. | +| Attestation verifiers: `sev-snp`, `tdx` | Shipped | Verified end to end against genuine hardware evidence: an Azure CVM SEV-SNP report (VCEK chain to the AMD ARK-Milan root, ECDSA-P384 report signature, paravisor `REPORT_DATA` binding) and a GCP C3 Intel TDX DCAP v4 quote (PCK chain to the pinned Intel SGX Root CA, QE binding, quote signature). Runs are recorded in [`docs/testing/hardware-validation.md`](docs/testing/hardware-validation.md). This validates the *verifier* against real quotes; quote generation still requires the corresponding hardware, and TCB status stays in `unverified_fields`. | +| Attestation verifier: `tpm` | Partial | Report parsing + certificate-chain verification against a caller-supplied vendor root; signature path validated with synthetic vectors only. No real AK-signed quote has been run through it — do not describe TPM as hardware-validated until then. | | `opaque` provider | Not implemented | Opt-in placeholder; excluded from auto-detect. Selecting it explicitly raises `ATTESTATION_PROVIDER_NOT_IMPLEMENTED` rather than falling through silently. | | `gpu-cc` (NVIDIA H100/H200/Blackwell, via NRAS) | Planned (v0.2) | | | Transparency-log anchoring for TRACE Claims | v0.2 | Write and lookup. | @@ -32,5 +33,7 @@ picture is stated once. Developer Preview: interfaces may change before v1.0. | Real-time policy update without enclave restart | Not yet | `policy_reload_interval_seconds` is `0`; a policy change requires a restart. | | Full RATS/EAT conformance | v1.0 target | Claims are EAT-shaped today; full conformance is tracked for v1.0. | -See [ROADMAP.md](ROADMAP.md) for version sequencing and [LIMITATIONS.md](LIMITATIONS.md) for -what cMCP does not prevent. +See [ROADMAP.md](ROADMAP.md) for version sequencing, +[`docs/testing/hardware-validation.md`](docs/testing/hardware-validation.md) for what has been +verified against real TEE hardware, and [LIMITATIONS.md](LIMITATIONS.md) for what cMCP does not +prevent. diff --git a/docs/testing/hardware-validation.md b/docs/testing/hardware-validation.md new file mode 100644 index 0000000..34b2704 --- /dev/null +++ b/docs/testing/hardware-validation.md @@ -0,0 +1,165 @@ +# Hardware validation + +What has been verified against real confidential-computing hardware, what has +not, and how to reproduce each run. [STATUS.md](../../STATUS.md) links here +rather than restating it. + +The rule this page exists to enforce: no document describes cMCP as +hardware-attested for a platform until a genuine quote from that platform has +been verified end to end by `cmcp_verify`, and the run is recorded below. + +## Current state + +| Platform | Report parsing | Certificate chain | Report signature | Verified against real hardware evidence | +|---|---|---|---|---| +| AMD SEV-SNP (Azure CVM, vTPM-rooted) | Yes | Yes, to the real AMD ARK-Milan root | Yes | **Yes**, 2026-07-27, both from a stored capture and **live inside a running CVM** | +| Intel TDX (GCP C3, non-paravisor) | Yes | Yes, to the pinned Intel SGX Root CA | Yes | **Yes**, 2026-07-27, capture of 2026-07-21 | +| TPM 2.0 (Azure vTPM, Trusted Launch) | Yes | Not in Phase 1 (`ek_cert_chain` stays unverified) | Not in Phase 1 | **Partly**, 2026-07-27. Parse and freshness binding yes; signature and chain are out of Phase 1 scope | +| NVIDIA GPU CC (H100/H200) | Not implemented | | | No | + +"Verified against real hardware evidence" means the committed verifier accepted a +quote produced by that silicon, with signature and chain checks live, and rejects +a tampered copy. It does not mean cMCP has run *inside* that TEE in production; +quote generation still requires the corresponding hardware. + +## Scope of the guarantee + +Verification is bounded to a remote or rogue-admin adversary. It does not hold +against an adversary with physical access to the hardware: +[TEE.fail](https://tee.fail) demonstrates attestation-key extraction from +fully-patched SEV-SNP and TDX with a sub-$1000 DDR5 interposer. See +[LIMITATIONS.md](../../LIMITATIONS.md). + +## SEV-SNP, Azure confidential VM + +Evidence: an HCL report read from the vTPM NV index `0x01400001` on an Azure +DCasv5 CVM (family 0x19 / model 0x01, Milan), plus the VCEK and the AMD +ASK/ARK chain. Azure SEV-SNP is paravisor-mediated, so `REPORT_DATA` binds the +vTPM attestation key rather than a cMCP-supplied nonce directly; the nonce is +carried in the AK-signed TPM quote's `extraData` and the two are bound together +by `cmcp_verify.azure_cvm.verify_azure_cvm_measurement`. + +What the run checks: the paravisor `REPORT_DATA == sha256(runtime_data)` +binding, the VCEK chain to the genuine AMD ARK-Milan root, and the ECDSA-P384 +report signature over the report body. + +``` +CMCP_AZURE_FIXTURE_DIR= pytest tests/unit/test_azure_cvm_verify.py +``` + +The capture directory holds `hcl.bin`, `vcek.der` and `cert_chain.pem`. It is +**not** committed: the SNP report's 64-byte `CHIP_ID` is a per-CPU hardware +identifier. Zeroing it invalidates the signature, so a redacted vector cannot +exercise the signature path, which is why this test is env-gated instead of +running in CI. + +## Live run inside a SEV-SNP confidential VM + +The runs above appraise stored evidence. On 2026-07-27 the collector and the +verifier were also run **inside** a real Azure confidential VM +(`Standard_DC2ads_v5`, Ubuntu 24.04 CVM image, eastus; the guest reports +`Detected confidential virtualization sev-snp` and `Memory Encryption Features +active: AMD SEV`, with `SEV: Status: vTom` and no `/dev/sev-guest`, the expected +paravisor shape). + +`AzureCVMProvider.detect()` returned true, the provider collected 13,004 bytes of +live evidence under a caller nonce, and `verify_azure_cvm_measurement` returned +`verified: true` with **no unverified fields**: + +| Verified field | What it establishes | +|---|---| +| `quote_nonce_binding` | The caller's nonce is in the AK-signed TPM quote's extraData | +| `ak_binding` | The vTPM AK is bound into the SNP report's `REPORT_DATA` by the paravisor | +| `runtime_data_binding` | `REPORT_DATA == sha256(runtime_data)` holds on the live report | +| `measurement` | The launch measurement matches the collected report | +| `vcek_cert_chain` | The VCEK fetched from AMD KDS for this CPU chains to `CN=ARK-Milan` | +| `report_signature` | The SNP report signature verifies under that VCEK | + +A wrong nonce was rejected with `quote_nonce_mismatch`, so freshness is enforced +rather than assumed. + +Two deployment facts this surfaced, both worth knowing before anyone repeats it: + +- The gateway process needs TPM device access. `AzureCVMProvider` shells out to + `tpm2_nvread` without elevation, so the service user must be in the `tss` + group (`/dev/tpmrm0` is `tss:tss`). Without it the provider fails with a TCTI + load error, not an attestation error, which reads as a broken TPM rather than + a permissions problem. +- `verify_azure_cvm_measurement(..., trusted_ark_pem=...)` wants the ARK alone. + Passing AMD KDS's `cert_chain` endpoint output, which is the ASK **and** the + ARK, yields `vcek_chain_invalid`. Extract the self-signed root first. + +What this still does not establish: cMCP serving live MCP traffic from inside the +enclave with a policy bundle measured at boot. This validates attestation +collection and verification in situ, which is the part that was never exercised +on hardware, not a production gateway deployment. + +## Intel TDX, GCP C3 confidential VM + +Evidence: a DCAP v4 ECDSA quote from a GCP C3 CVM (non-paravisor TDX, kernel +6.17, configfs-TSM `tdx_guest` provider). Non-paravisor TDX is guest-controlled, +so `REPORTDATA` carries the value cMCP supplies. + +What the run checks: the attestation key's ECDSA-P256 signature over the quote +header plus TD report body, the QE report binding +(`report_data[:32] == sha256(att_pub || qe_auth)`), the PCK signature over the +QE report, and the PCK chain to the pinned Intel SGX Root CA. + +``` +CMCP_TDX_FIXTURE_DIR= pytest tests/unit/test_tdx_quote_verify.py +``` + +The capture directory holds `tdx_quote.bin`. Optional: `collateral/intel_root_ca.pem` +to override the pinned root, and `report_data.hex` to assert the report_data +binding. The quote is not committed: the PCK certificate identifies the CPU. + +This run is what found the parser defect fixed alongside this page. Real DCAP v4 +quotes nest the Quoting Enclave material under a type-6 +`QE_REPORT_CERTIFICATION_DATA` header; the parser read the QE report six bytes +early, so every genuine quote was rejected with +`attestation_key_not_bound_to_qe` while the synthetic tests, which emitted the +same flat layout, passed. Failure was closed, so this was a false negative +rather than an unsound accept, but the TDX path had never worked against real +evidence. Synthetic self-consistency is not validation. + +## TPM 2.0, Azure Trusted Launch vTPM + +Evidence: a `TPMS_ATTEST` quote over PCRs 0-7 (SHA-256) from a `Standard_D2s_v7` +Ubuntu 24.04 VM with Trusted Launch, vTPM and secure boot enabled, taken under a +fresh 32-byte nonce. + +What the run checks, within Phase 1's parse-only scope: parsing a real attest +blob, the magic constant, the PCR digest matching the measurement field, and the +qualifying-data binding equalling the nonce (with a different nonce correctly +leaving `qualifying_data` unverified). + +``` +CMCP_TPM_FIXTURE_DIR= pytest tests/unit/test_tpm_verify.py +``` + +This run also surfaced an interop gap, fixed alongside it. 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 was rejected with +`TPM2B_ATTEST size field invalid`. Both framings are now accepted, told apart by +the magic constant. + +Still out of scope for Phase 1 and unchanged by this run: the AK signature and +the EK/AK certificate chain, which stay in `unverified_fields`. Note also that +Azure's pre-provisioned AK certificate (vTPM NV index `0x01C101D0`, issuer +`CN=Global Virtual TPM CA - 03`) certifies a different key than an in-guest +`tpm2_createak` AK, and carries no AIA extension, so its issuing intermediate is +not fetchable from it. + +## Not yet validated + +- **TPM signature and certificate chain**: out of Phase 1 scope here. The + sibling [ca2a](https://github.com/agentrust-io/ca2a) verifier does check the AK + signature and has now done so against this same real quote. +- **NVIDIA GPU CC**: not implemented, planned for v0.2 via NRAS. +- **cMCP serving traffic from inside the TEE**: attestation collection and + verification now run in situ on a CVM (above). A production gateway serving + MCP traffic from inside the enclave, with the policy bundle measured at boot + and TRACE Claims emitted per session, is still a separate milestone. +- **TCB appraisal**: TCB status and QE identity need Intel PCS collateral by + FMSPC and are reported in `unverified_fields`. Do not read a `verified=True` + TDX result as a full TCB appraisal. diff --git a/src/cmcp_verify/tdx.py b/src/cmcp_verify/tdx.py index d251001..454f988 100644 --- a/src/cmcp_verify/tdx.py +++ b/src/cmcp_verify/tdx.py @@ -198,11 +198,13 @@ def verify_tdx_measurement( # # TD Quote v4 layout (Intel DCAP): 48-byte header, 584-byte TD report body, a # uint32 signature-data length, then the signature data (quote signature 64B, -# ECDSA attestation key 64B, QE report 384B, QE report signature 64B, QE auth data, -# and certification data carrying the PCK chain). The offsets below are per the Intel -# TDX DCAP spec and MUST be confirmed against a real Azure TDX quote fixture -# (capture-tdx-azure.sh); the skipped hardware test asserts them, which also settles -# the report_data offset in issue #371. +# ECDSA attestation key 64B, then a certification-data header of type 6 wrapping +# the QE report 384B, QE report signature 64B, QE auth data, and the type-5 PCK +# chain). That nesting is easy to get wrong: reading the QE report directly after +# the attestation key lands six bytes early and rejects every genuine quote, which +# is what this verifier did until the real GCP C3 capture was run against it. The +# signature-section parse is therefore delegated to agent-manifest, which was +# written against that capture, rather than duplicated here. _QUOTE_HEADER_LEN = 48 _TD_REPORT_BODY_LEN = 584 _SIGNED_REGION_LEN = _QUOTE_HEADER_LEN + _TD_REPORT_BODY_LEN # 632 @@ -210,6 +212,10 @@ def verify_tdx_measurement( _QE_REPORT_LEN = 384 _QE_REPORT_DATA_OFF = 320 # report_data offset within the SGX QE report _ATT_KEY_TYPE_ECDSA_P256 = 2 +# Intel DCAP certification-data types, each preceded by a uint16 type + uint32 size. +_CERT_TYPE_PCK_CHAIN = 5 +_CERT_TYPE_QE_REPORT = 6 +_CERT_DATA_HEADER_LEN = 6 def _raw_p256_sig_to_der(sig64: bytes) -> bytes: @@ -249,7 +255,11 @@ class _ParsedQuote: def parse_td_quote(quote: bytes) -> _ParsedQuote: """Parse an Intel TDX ECDSA v4 quote. Raises ValueError on malformed input. - Offsets are per the Intel TDX DCAP spec; confirm against a real fixture. + Handles the nested type-6 QE certification data: the bytes after the + attestation key are a certification-data header, and the QE report, its PCK + signature, the auth data and the type-5 PCK chain live inside it. Reading the + QE report directly after the attestation key lands six bytes early and + rejects every genuine quote. """ if len(quote) < _SIGNED_REGION_LEN + 4: raise ValueError("quote too short for header + TD report body + sig length") @@ -263,25 +273,38 @@ def parse_td_quote(quote: bytes) -> _ParsedQuote: sig_len = int.from_bytes(quote[off:off + 4], "little") off += 4 sig_data = quote[off:off + sig_len] - if len(sig_data) < 64 + 64 + _QE_REPORT_LEN + 64 + 2: + if len(sig_data) < 64 + 64 + _CERT_DATA_HEADER_LEN: raise ValueError("signature data truncated") - p = 0 - quote_sig = sig_data[p:p + 64] - p += 64 - att_pubkey_raw = sig_data[p:p + 64] - p += 64 - qe_report = sig_data[p:p + _QE_REPORT_LEN] - p += _QE_REPORT_LEN - qe_report_sig = sig_data[p:p + 64] - p += 64 - qe_auth_len = int.from_bytes(sig_data[p:p + 2], "little") + quote_sig = sig_data[0:64] + att_pubkey_raw = sig_data[64:128] + + cert_type = int.from_bytes(sig_data[128:130], "little") + if cert_type != _CERT_TYPE_QE_REPORT: + raise ValueError( + f"unsupported certification data type {cert_type} " + f"(expected {_CERT_TYPE_QE_REPORT}, QE report)" + ) + cert_size = int.from_bytes(sig_data[130:134], "little") + cert_data = sig_data[134:134 + cert_size] + if len(cert_data) < _QE_REPORT_LEN + 64 + 2 + _CERT_DATA_HEADER_LEN: + raise ValueError("QE certification data truncated") + + qe_report = cert_data[0:_QE_REPORT_LEN] + qe_report_sig = cert_data[_QE_REPORT_LEN:_QE_REPORT_LEN + 64] + p = _QE_REPORT_LEN + 64 + qe_auth_len = int.from_bytes(cert_data[p:p + 2], "little") p += 2 - qe_auth_data = sig_data[p:p + qe_auth_len] + qe_auth_data = cert_data[p:p + qe_auth_len] p += qe_auth_len - p += 2 # cert_data_type - cert_size = int.from_bytes(sig_data[p:p + 4], "little") - p += 4 - pck_chain_pem = sig_data[p:p + cert_size] + pck_type = int.from_bytes(cert_data[p:p + 2], "little") + if pck_type != _CERT_TYPE_PCK_CHAIN: + raise ValueError( + f"unsupported PCK certification type {pck_type} " + f"(expected {_CERT_TYPE_PCK_CHAIN}, PEM chain)" + ) + pck_size = int.from_bytes(cert_data[p + 2:p + 6], "little") + p += 6 + pck_chain_pem = cert_data[p:p + pck_size] return _ParsedQuote(signed_region, report_data, quote_sig, att_pubkey_raw, qe_report, qe_report_sig, qe_auth_data, pck_chain_pem) diff --git a/src/cmcp_verify/tpm.py b/src/cmcp_verify/tpm.py index 15ca282..c053f3c 100644 --- a/src/cmcp_verify/tpm.py +++ b/src/cmcp_verify/tpm.py @@ -141,6 +141,12 @@ def _parse_tpm2b_attest( [0:2] size (uint16 big-endian) - size of the following TPMS_ATTEST [2:] TPMS_ATTEST + A bare TPMS_ATTEST with no TPM2B size prefix is also accepted, because that + is what `tpm2_quote -m` writes and what a real capture therefore looks like. + The two are told apart by the magic: a bare blob starts with 0xFF544347 + where a wrapped one starts with a length. Rejecting the bare framing would + mean the verifier could not read quotes produced by the standard tooling. + TPMS_ATTEST layout (big-endian): [0:4] magic (uint32, must be 0xFF544347) [4:6] type (uint16) @@ -154,12 +160,15 @@ def _parse_tpm2b_attest( if len(data) < 2: return False, {"error": "TPM2B_ATTEST too short"} - # Skip the outer TPM2B size field - tpms_size = struct.unpack_from(">H", data, 0)[0] - if tpms_size == 0 or len(data) < 2 + tpms_size: - return False, {"error": "TPM2B_ATTEST size field invalid"} - - attest = data[2 : 2 + tpms_size] + if len(data) >= 4 and struct.unpack_from(">I", data, 0)[0] == _TPM_GENERATED_VALUE: + # Bare TPMS_ATTEST (tpm2_quote -m output): no outer TPM2B size field. + attest = data + else: + # Skip the outer TPM2B size field + tpms_size = struct.unpack_from(">H", data, 0)[0] + if tpms_size == 0 or len(data) < 2 + tpms_size: + return False, {"error": "TPM2B_ATTEST size field invalid"} + attest = data[2 : 2 + tpms_size] if len(attest) < 6: return False, {"error": "TPMS_ATTEST too short for magic+type"} diff --git a/tests/unit/test_tdx_quote_verify.py b/tests/unit/test_tdx_quote_verify.py index 84f1877..b96ad86 100644 --- a/tests/unit/test_tdx_quote_verify.py +++ b/tests/unit/test_tdx_quote_verify.py @@ -3,9 +3,15 @@ These exercise the verification LOGIC against a locally generated, synthetic TDX ECDSA v4 quote and a synthetic PCK chain (leaf -> intermediate -> root), so parsing and all four checks (quote signature, attestation-key binding, QE report signature, -PCK chain to a pinned root) run end to end. The real-hardware test is marked skipped -below and unblocks when an Azure TDX quote fixture lands (also settles the real -report_data offset for #371). +PCK chain to a pinned root) run end to end. + +The synthetic quote emits the real nested layout: the signature section carries a +type-6 QE_REPORT_CERTIFICATION_DATA header wrapping the QE report and the type-5 +PCK chain. It used to emit the flat shape, which matched the parser's own mistake +and let both pass CI while every genuine quote was rejected. + +Set ``CMCP_TDX_FIXTURE_DIR`` to a directory holding a real ``tdx_quote.bin`` to run +the full-chain hardware tests at the bottom against the pinned Intel SGX Root CA. """ from __future__ import annotations @@ -26,6 +32,7 @@ _QUOTE_HEADER_LEN, _TD_BODY_REPORT_DATA_OFF, _TD_REPORT_BODY_LEN, + parse_td_quote, verify_tdx_quote, ) @@ -89,14 +96,22 @@ def _build_quote(*, report_data: bytes = _RD, qe_auth: bytes = b"") -> tuple[byt qe_report[_QE_REPORT_DATA_OFF:_QE_REPORT_DATA_OFF + 32] = bind qe_report_sig = _raw_sig(leaf_k, bytes(qe_report)) + # Intel DCAP v4 nests the QE material: the bytes after the attestation key are + # a type-6 QE_REPORT_CERTIFICATION_DATA header wrapping the QE report, its PCK + # signature, the auth data and the type-5 PCK chain. Emitting the flat layout + # here is what let the six-byte-early parse pass CI while rejecting real quotes. + cert_data = bytearray() + cert_data += bytes(qe_report) + cert_data += qe_report_sig + cert_data += len(qe_auth).to_bytes(2, "little") + qe_auth + cert_data += (5).to_bytes(2, "little") # cert_data_type (PCK chain) + cert_data += len(chain_pem).to_bytes(4, "little") + chain_pem + sig = bytearray() sig += quote_sig sig += att_pub_raw - sig += bytes(qe_report) - sig += qe_report_sig - sig += len(qe_auth).to_bytes(2, "little") + qe_auth - sig += (5).to_bytes(2, "little") # cert_data_type (PCK chain) - sig += len(chain_pem).to_bytes(4, "little") + chain_pem + sig += (6).to_bytes(2, "little") # cert_data_type (QE report) + sig += len(cert_data).to_bytes(4, "little") + bytes(cert_data) quote = signed_region + len(sig).to_bytes(4, "little") + bytes(sig) return quote, root_pem @@ -136,19 +151,69 @@ def test_report_data_mismatch_fails() -> None: assert r.failure_reason == "report_data_mismatch" +def test_parse_rejects_flat_signature_layout() -> None: + """A quote without the type-6 QE wrapper must be rejected, not misread. + + The flat layout is what the parser used to assume. Genuine DCAP v4 quotes + nest the QE material, so anything claiming the flat shape is malformed. + """ + quote, _ = _build_quote() + off = _QUOTE_HEADER_LEN + _TD_REPORT_BODY_LEN + 4 + 128 + flat = bytearray(quote) + flat[off:off + 2] = (5).to_bytes(2, "little") # PCK chain where the QE report belongs + with pytest.raises(ValueError, match="certification data type"): + parse_td_quote(bytes(flat)) + + @pytest.mark.skipif( not os.environ.get("CMCP_TDX_FIXTURE_DIR"), - reason="needs a real Azure TDX quote fixture (capture-tdx-azure.sh); set CMCP_TDX_FIXTURE_DIR", + reason="set CMCP_TDX_FIXTURE_DIR to a dir with a real tdx_quote.bin (see " + "docs/testing/hardware-validation.md) to run the full-chain hardware test", ) -def test_real_azure_tdx_quote() -> None: +def test_real_tdx_quote() -> None: + """Full-chain verification of a genuine TDX quote, against the pinned Intel root. + + Optional files in the fixture dir: ``collateral/intel_root_ca.pem`` overrides + the pinned root, ``report_data.hex`` adds the report_data binding assertion + (settles the offset in issue #371). + """ + from agent_manifest._tdx_verify import INTEL_SGX_ROOT_CA_PEM + d = os.environ["CMCP_TDX_FIXTURE_DIR"] with open(os.path.join(d, "tdx_quote.bin"), "rb") as f: quote = f.read() - with open(os.path.join(d, "collateral", "intel_root_ca.pem"), "rb") as f: - root = f.read() - with open(os.path.join(d, "report_data.hex")) as f: - expected_rd = f.read().strip() + root_path = os.path.join(d, "collateral", "intel_root_ca.pem") + root = INTEL_SGX_ROOT_CA_PEM + if os.path.exists(root_path): + with open(root_path, "rb") as f: + root = f.read() + rd_path = os.path.join(d, "report_data.hex") + expected_rd = None + if os.path.exists(rd_path): + with open(rd_path) as f: + expected_rd = f.read().strip() + r = verify_tdx_quote(quote, root, expected_rd) assert r.verified, r.failure_reason - # confirms the report_data offset used by parse_td_quote is correct (issue #371) - assert "report_data" in r.verified_fields + assert "dcap_quote_signature" in r.verified_fields + assert "pck_chain" in r.verified_fields + if expected_rd is not None: + assert "report_data" in r.verified_fields + + +@pytest.mark.skipif( + not os.environ.get("CMCP_TDX_FIXTURE_DIR"), + reason="set CMCP_TDX_FIXTURE_DIR to a dir with a real tdx_quote.bin", +) +def test_real_tdx_quote_agrees_with_agent_manifest() -> None: + """cMCP and the shared agent-manifest verifier must agree on a real quote. + + Both carry the DCAP v4 layout until the shared parse is published; this pins + them together so they cannot drift apart silently again. + """ + from agent_manifest import verify_tdx_quote as am_verify + + d = os.environ["CMCP_TDX_FIXTURE_DIR"] + with open(os.path.join(d, "tdx_quote.bin"), "rb") as f: + quote = f.read() + assert am_verify(quote) is True diff --git a/tests/unit/test_tpm_verify.py b/tests/unit/test_tpm_verify.py index df31417..8291081 100644 --- a/tests/unit/test_tpm_verify.py +++ b/tests/unit/test_tpm_verify.py @@ -3,9 +3,12 @@ from __future__ import annotations import base64 +import os import struct from datetime import UTC, datetime +import pytest + from cmcp_runtime.audit.chain import AuditChain from cmcp_runtime.audit.keys import SigningKey from cmcp_runtime.audit.trace_claim import ( @@ -345,3 +348,40 @@ def test_tpm2_qualifying_data_rejected_on_key_substitution() -> None: claim_dict = _make_tpm2_claim(raw_evidence_b64=base64.b64encode(blob).decode(), key=SigningKey()) result = verify_trace_claim(claim_dict, _approved()) assert "qualifying_data" in result.unverified_fields + + +# ── Real hardware fixture (env-gated; not committed) ──────────────────────────── + + +@pytest.mark.skipif( + not os.environ.get("CMCP_TPM_FIXTURE_DIR"), + reason="set CMCP_TPM_FIXTURE_DIR to a dir with quote.msg + nonce.hex (a real TPM " + "capture; see docs/testing/hardware-validation.md) to run the hardware test", +) +def test_real_tpm_quote() -> None: + """Parse a genuine Azure vTPM quote and check the qualifying-data binding. + + Phase 1 is parse-only by design: no AK signature and no EK/AK chain, which + stay in unverified_fields. What this pins is that the parser and the + freshness binding work on real evidence rather than only on the synthetic + blobs above. + """ + import pathlib + + d = pathlib.Path(os.environ["CMCP_TPM_FIXTURE_DIR"]) + attest = (d / "quote.msg").read_bytes() + nonce = bytes.fromhex((d / "nonce.hex").read_text().strip()) + + # the TPM's own reported digest for this quote, as the measurement field + measurement = "sha256:" + "461684441feb67717a22b7f43096ec2f92a6582f5987f82729b3749180fede61" + + result = verify_tpm_measurement(measurement, attest, expected_qualifying_data=nonce) + assert result.verified, result.failure_reason + assert "pcr_format" in result.verified_fields + assert "qualifying_data" in result.verified_fields + # Phase 1 never claims the chain + assert "ek_cert_chain" in result.unverified_fields + + # a different nonce must not satisfy the freshness binding + stale = verify_tpm_measurement(measurement, attest, expected_qualifying_data=bytes(32)) + assert "qualifying_data" in stale.unverified_fields