diff --git a/CHANGELOG.md b/CHANGELOG.md index c53c446..ce06034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Certificate-chain verification now delegates to agent-manifest's shared generic verifier (`agent-manifest>=0.5`) instead of ca2a's own copy. `ca2a_verify.verify_cert_chain` (used by the SEV-SNP VCEK, TDX PCK, and TPM AK chains alike) is now a thin wrapper over `agent_manifest.verify_cert_chain`, re-raising `CertChainError` as `AttestationFailed` to preserve ca2a's contract. This removes the org's last duplicated cert-chain verifier; ca2a keeps its own report/quote parsers, per-format signature checks, appraisal shapes, trust-root pinning, and `verify_offer` semantics. Behavior unchanged (all 201 tests pass unchanged). - Bumped `agentrust-trace` to `>=0.4`. The previous `<0.4` cap worked around the published 0.3.0 lacking the TRACE A2A `delegation` block that cA2A records carry; 0.4.0 ships that block, so the cap is removed. cA2A still owns the diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 342d892..b412069 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -12,7 +12,7 @@ cA2A is a pre-release profile in active design. This document states plainly wha - **Hardware-attested live binding.** The live transport and handshake run today in **software mode only** (the reference server/client above, `assurance="none"`). The remaining gap is hardware: driving the `verifier` seam in `ca2a_runtime.attestation` off a real SEV-SNP, TDX, or TPM quote (wrapping `ca2a_verify`) so the peer's channel key is bound to a hardware-verified enclave measurement, and relying on the enclave to hold the channel private key. There is also no CLI listener (`ca2a start`); serving is via `ca2a_runtime.transport.server.serve`. A software-mode live call is Tier 2 progress, not a claim that cA2A is attested across trust domains. - **Sealed peer channel (hardware property).** The channel is implemented: a payload is sealed to the peer's attested X25519 key (X25519 ECDH, HKDF-SHA256, ChaCha20-Poly1305), and only the holder of the peer's private key can open it. On a live call the handshake now gates the seal on a channel key the caller has appraised, but in software mode that appraisal is `assurance="none"`. Until the seal is bound to a hardware-verified measurement (above), do not assume a payload is confined to a specific attested measurement. Adapter-decoded `sealed_payload` bytes are opaque ciphertext only. -- **Real hardware attestation.** The **SEV-SNP verifier is implemented**: report parsing, VCEK certificate chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. The chain path is validated against the genuine AMD Milan root chain; the report-signature path is validated with synthetic vectors, since a real report plus VCEK pair needs SEV-SNP hardware. Report generation (`SevSnpProvider.attest`) still requires a real SEV-SNP guest. The **Intel TDX verifier** (DCAP Quote v4: PCK chain to the genuine Intel SGX Root CA, QE report, attestation-key binding, quote signature, MRTD binding) and the **TPM 2.0 verifier** (AK chain to a caller-supplied vendor root, AK signature, magic/type, qualifying-data and PCR-digest binding) are also implemented and synthetic-vector validated; quote generation needs the respective hardware. Until a backend verifies a real quote end to end against a golden measurement on hardware, cA2A must not be described as fully attested across trust domains. +- **Real hardware attestation.** The **SEV-SNP and Intel TDX verifiers now appraise genuine hardware evidence end to end**: a real Azure CVM SEV-SNP report (VCEK chain to the AMD ARK-Milan root, ECDSA-P384 report signature, measurement binding) and a real GCP C3 DCAP v4 TDX quote (PCK chain to the Intel SGX Root CA, QE binding, quote signature, MRTD binding), both fail-closed and both rejecting a tampered copy. Runs are recorded in [docs/hardware-validation.md](docs/hardware-validation.md). The **TPM 2.0 verifier** (AK chain to a caller-supplied vendor root, AK signature, magic/type, qualifying-data and PCR-digest binding) is implemented but synthetic-vector validated only. Quote *generation* still requires the respective hardware for all three. This validates the verifier, not a running attested peer: until the `verifier` seam in `ca2a_runtime.attestation` is driven off a live quote on a confidential VM, cA2A must not be described as attested across trust domains. ## Out of scope diff --git a/ROADMAP.md b/ROADMAP.md index 78ff695..42d0dc5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,11 +30,11 @@ Already implemented and tested elsewhere; cA2A depends on it rather than reimple Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via QVL/PCS, TPM AK cert + checkquote). This is a dependency for any cross-operator trust claim, single-agent or multi-agent, and is shared with cmcp. At least one real hardware backend must land before cA2A is marketed as attested across trust domains, so the demo matches the claim. -- **SEV-SNP verifier: landed.** Report parsing, VCEK chain verification (validated against the real AMD Milan root), ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. Report generation still requires a real SEV-SNP guest. See `ca2a_verify.sev_snp` and [docs/spec/attestation.md](docs/spec/attestation.md). -- **TDX verifier: landed.** DCAP Quote v4 parsing, PCK chain to the genuine Intel SGX Root CA, QE report signature, attestation-key binding, quote signature, and MRTD binding, all fail-closed. Quote generation requires a real TDX guest. See `ca2a_verify.tdx`. +- **SEV-SNP verifier: landed and validated on real evidence.** Report parsing, VCEK chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed, run against a genuine Azure CVM report (see [docs/hardware-validation.md](docs/hardware-validation.md)). Report generation still requires a real SEV-SNP guest. See `ca2a_verify.sev_snp` and [docs/spec/attestation.md](docs/spec/attestation.md). +- **TDX verifier: landed and validated on real evidence.** DCAP Quote v4 parsing (including the nested type-6 QE certification data), PCK chain to the genuine Intel SGX Root CA, QE report signature, attestation-key binding, quote signature, and MRTD binding, all fail-closed, run against a genuine GCP C3 quote. Quote generation requires a real TDX guest. See `ca2a_verify.tdx`. - **TPM 2.0 verifier: landed.** TPMS_ATTEST parsing, AK chain to a caller-supplied vendor root, AK signature (ECDSA or RSA), magic/type checks, and qualifying-data/PCR-digest binding, all fail-closed. Quote generation requires a real TPM. See `ca2a_verify.tpm`. - **Cross-operator attestation (C6): validated in software.** A two-operator harness (SEV-SNP verifier + measurement pinning + sealed channel) shows independent keys, mutual attestation, confidential cross-operator delegation, and binary-swap detection. All six claims (C1-C6) are now validated experiments. -- **Pending:** end-to-end validation of the SEV-SNP, TDX, and TPM signature paths against real hardware quotes on a confidential VM. The transport that parses A2A messages into a `PeerRequest` has **landed** (`ca2a_runtime.transport.a2a`), running in software mode; the hardware seam is the `verifier` callable in `ca2a_runtime.attestation`. +- **Pending:** the TPM signature path against a real quote, and a live attested peer (the `verifier` seam driven off a hardware quote on a running confidential VM). SEV-SNP and TDX appraisal of real evidence is done. The transport that parses A2A messages into a `PeerRequest` has **landed** (`ca2a_runtime.transport.a2a`), running in software mode; the hardware seam is the `verifier` callable in `ca2a_runtime.attestation`. ## v1.0: Stable profile diff --git a/docs/hardware-validation.md b/docs/hardware-validation.md new file mode 100644 index 0000000..ac6b26d --- /dev/null +++ b/docs/hardware-validation.md @@ -0,0 +1,82 @@ +# Hardware validation + +What `ca2a_verify` has been run against real confidential-computing hardware, +what it has not, and how to reproduce each run. [LIMITATIONS.md](../LIMITATIONS.md) +and [ROADMAP.md](../ROADMAP.md) link here rather than restating it. + +The rule: no document describes cA2A as attested for a platform until a genuine +quote from that platform has been verified end to end by the committed verifier, +and the run is recorded below. + +## Current state + +| Platform | 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, capture of 2026-07-20 | +| Intel TDX (GCP C3, non-paravisor) | Yes | Yes, to the real Intel SGX Root CA | Yes | **Yes**, 2026-07-27, capture of 2026-07-21 | +| TPM 2.0 | Yes | Yes, to a caller-supplied vendor root | Yes | No. Synthetic vectors only | + +## What these runs do and do not establish + +They establish that the appraisal path in `ca2a_verify` accepts genuine evidence +from that silicon and fails closed on a tampered copy. They do **not** establish +that cA2A runs inside a TEE: quote *generation* still requires the hardware, and +the `verifier` seam in `ca2a_runtime.attestation` is not yet driven off a live +quote on a running peer. A live cA2A call is still software mode +(`assurance="none"`). + +So the claim that is now true is "the cA2A verifier appraises real SEV-SNP and +TDX evidence." The claim that is still not true is "cA2A is attested across trust +domains." The second needs a peer whose channel key is bound to a hardware +measurement on a live confidential VM, which stays on the critical path in +[ROADMAP.md](../ROADMAP.md). + +Verification is also bounded to a remote or rogue-admin adversary, not to one +with physical access: [TEE.fail](https://tee.fail) extracts attestation keys from +fully-patched SEV-SNP and TDX with a sub-$1000 DDR5 interposer. + +## SEV-SNP, Azure confidential VM + +Evidence: an SNP report and its VCEK plus the AMD ASK/ARK chain, captured from an +Azure DCasv5 CVM (family 0x19 / model 0x01, Milan) via the vTPM NV index +`0x01400001`. Azure SEV-SNP is paravisor-mediated, so `REPORT_DATA` binds the +vTPM attestation key rather than a cA2A-supplied value. + +``` +CA2A_SNP_FIXTURE_DIR= pytest tests/unit/test_sev_snp.py +``` + +The directory holds `snp_report.bin`, `vcek.der` and `cert_chain.pem`. Not +committed: the report's 64-byte `CHIP_ID` is a per-CPU hardware identifier, and +zeroing it invalidates the signature, so a redacted vector cannot exercise the +signature path. + +## 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). + +``` +CA2A_TDX_QUOTE= pytest tests/unit/test_tdx.py +``` + +Not committed: the PCK certificate identifies the CPU. + +This run found the parser defect fixed alongside this page. Genuine DCAP v4 +quotes nest the Quoting Enclave material under a type-6 +`QE_REPORT_CERTIFICATION_DATA` header; `TdxQuote.parse` read the QE report six +bytes early and threw on every real quote. The synthetic fixture emitted the same +flat layout, so the tests 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. Synthetic self-consistency is not validation. + +## Not yet validated + +- **TPM 2.0**: implemented and synthetic-vector validated; needs a real AK-signed + quote plus a vendor AK certificate chain. +- **Live attested peer binding**: the handshake gating the sealed channel on a + verified channel key runs in software mode. Driving it off a real quote on a + confidential VM is the remaining hardware property, and the precondition for + describing cA2A as attested across trust domains. +- **Cross-operator run on real hardware**: the two-operator harness + (`examples/cross-operator-delegation`) is validated in software. diff --git a/pyproject.toml b/pyproject.toml index eca3b67..7c1afeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,9 @@ dependencies = [ # (see ROADMAP Tier 0). Pin a compatible minor; cross-repo skew is a known # risk documented in LIMITATIONS.md. "agentrust-trace>=0.4", + # Shared hardware-attestation verification (generic cert-chain verifier, + # SNP/TDX/TPM primitives) consumed via PyPI instead of duplicated per repo. + "agent-manifest>=0.5", "rfc8785>=0.1", ] diff --git a/src/ca2a_runtime/tee/tdx.py b/src/ca2a_runtime/tee/tdx.py index e90a839..607fed4 100644 --- a/src/ca2a_runtime/tee/tdx.py +++ b/src/ca2a_runtime/tee/tdx.py @@ -7,10 +7,11 @@ :mod:`ca2a_verify.tdx`. Producing a quote requires a real TDX guest, so :meth:`TdxProvider.attest` fails -closed off hardware. Byte offsets follow the Intel DCAP Quote v4 layout; the -verifier is exercised against synthetic self-consistent vectors plus the real -Intel SGX Root CA in the test suite. End-to-end validation against a real -hardware quote requires a TDX guest and remains open. +closed off hardware. Byte offsets follow the Intel DCAP Quote v4 layout, including +the nested type-6 QE certification data that wraps the QE report and the PCK +chain. The verifier is exercised against synthetic self-consistent vectors plus +the real Intel SGX Root CA in the test suite, and against a genuine GCP C3 quote +when ``CA2A_TDX_QUOTE`` points at one; see ``docs/hardware-validation.md``. """ from __future__ import annotations @@ -38,7 +39,10 @@ QE_REPORT_DATA_OFFSET = 320 # within the QE SGX report TEE_TYPE_TDX = 0x81 +# 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 TDX_GUEST_DEVICE = "/dev/tdx_guest" @@ -79,17 +83,34 @@ def parse(cls, blob: bytes) -> TdxQuote: pos += QUOTE_SIG_LEN att_key = blob[pos : pos + ATT_KEY_LEN] pos += ATT_KEY_LEN - qe_report = blob[pos : pos + QE_REPORT_LEN] - pos += QE_REPORT_LEN - qe_report_sig = blob[pos : pos + QUOTE_SIG_LEN] - pos += QUOTE_SIG_LEN - (qe_auth_len,) = struct.unpack_from(" None: """Verify a leaf-to-root certificate chain against a set of trusted roots. - ``chain`` is ordered leaf first (VCEK), root last (ARK). Each certificate - must be directly issued by the next, and the final certificate must match a - trusted root by fingerprint. Raises AttestationFailed on any failure. + ``chain`` is ordered leaf first (VCEK), root last (ARK). Delegates to + agent-manifest's shared, algorithm-agnostic cert-chain verifier (one + implementation across the org, consumed via PyPI) and re-raises its failure + as AttestationFailed to preserve ca2a's error contract. Used for the SEV-SNP + VCEK chain, the TDX PCK chain, and the TPM AK chain alike. Raises + AttestationFailed on any failure. """ - if not chain: - raise AttestationFailed("empty certificate chain") - - for i in range(len(chain) - 1): - child, issuer = chain[i], chain[i + 1] - try: - child.verify_directly_issued_by(issuer) - except (ValueError, TypeError, InvalidSignature) as exc: - raise AttestationFailed( - f"certificate at position {i} is not validly issued by the next", - detail=str(exc), - ) from exc - - root = chain[-1] - trusted = {c.fingerprint(SHA256()) for c in trusted_roots} - if root.fingerprint(SHA256()) not in trusted: + from agent_manifest import CertChainError + from agent_manifest import verify_cert_chain as _shared_verify_cert_chain + + try: + _shared_verify_cert_chain(chain, trusted_roots) + except CertChainError as exc: raise AttestationFailed( - "chain root is not a trusted AMD root", - detail=root.subject.rfc4514_string(), - ) + "certificate chain verification failed", detail=str(exc) + ) from exc def verify_sev_snp_report( diff --git a/tests/unit/test_sev_snp.py b/tests/unit/test_sev_snp.py index a1fe604..0bf3ee5 100644 --- a/tests/unit/test_sev_snp.py +++ b/tests/unit/test_sev_snp.py @@ -8,6 +8,7 @@ from __future__ import annotations +import os import struct from datetime import UTC, datetime, timedelta from pathlib import Path @@ -157,3 +158,32 @@ def test_provider_detect_and_attest() -> None: assert SevSnpProvider.detect() is False # no /dev/sev-guest in this environment with pytest.raises(AttestationUnsupported): SevSnpProvider().attest("deadbeef", "nonce") + + +@pytest.mark.skipif( + not os.environ.get("CA2A_SNP_FIXTURE_DIR"), + reason="set CA2A_SNP_FIXTURE_DIR to a dir with snp_report.bin + vcek.der + " + "cert_chain.pem (a real SEV-SNP capture) to run the hardware test", +) +def test_real_snp_report_verifies_to_the_amd_root() -> None: + """Full-chain appraisal of a genuine SEV-SNP report. + + The capture is not committed: the report's 64-byte CHIP_ID is a per-CPU + hardware identifier. See docs/hardware-validation.md. + """ + d = Path(os.environ["CA2A_SNP_FIXTURE_DIR"]) + report = (d / "snp_report.bin").read_bytes() + vcek = x509.load_der_x509_certificate((d / "vcek.der").read_bytes()) + rest = x509.load_pem_x509_certificates((d / "cert_chain.pem").read_bytes()) + ark = rest[-1] + assert ark.subject == ark.issuer, "last cert in the chain must be the self-signed ARK" + + parsed = verify_sev_snp_report(report, [vcek, *rest], trusted_roots=[ark]) + assert len(parsed.measurement) == 48 + assert parsed.measurement != bytes(48) + + # and it fails closed on a flipped bit in the signed body + tampered = bytearray(report) + tampered[0x50] ^= 0x01 + with pytest.raises(AttestationFailed): + verify_sev_snp_report(bytes(tampered), [vcek, *rest], trusted_roots=[ark]) diff --git a/tests/unit/test_tdx.py b/tests/unit/test_tdx.py index 9a8841d..ffc5af7 100644 --- a/tests/unit/test_tdx.py +++ b/tests/unit/test_tdx.py @@ -9,6 +9,7 @@ from __future__ import annotations import hashlib +import os import struct from pathlib import Path @@ -66,9 +67,14 @@ def build_quote(mrtd: bytes, report_data: bytes, *, root_key, root_name="test-in qe_report_sig = _sig_rs(pck_key, bytes(qe_report)) pem = pck.public_bytes(Encoding.PEM) + root.public_bytes(Encoding.PEM) - sig = (quote_sig + att_raw + bytes(qe_report) + qe_report_sig - + struct.pack(" None: assert TdxProvider.detect() is False with pytest.raises(AttestationUnsupported): TdxProvider().attest("deadbeef", "nonce") + + +def test_flat_signature_layout_rejected(quote_and_root) -> None: + """A quote without the type-6 QE wrapper is malformed, not silently misread. + + The flat shape is what the parser used to assume; genuine DCAP v4 quotes nest + the QE material, so anything claiming the flat layout must fail closed. + """ + off = 48 + 584 + 4 + 128 # header + TD report + sig_len + quote_sig + att_key + flat = bytearray(quote_and_root["quote"]) + flat[off:off + 2] = struct.pack(" None: + """Full-chain appraisal of a genuine TDX quote (see docs/hardware-validation.md).""" + from ca2a_verify.tdx import verify_tdx_quote + + intel_root = x509.load_pem_x509_certificates(FIXTURE.read_bytes())[0] + quote_bytes = Path(os.environ["CA2A_TDX_QUOTE"]).read_bytes() + quote = verify_tdx_quote(quote_bytes, trusted_roots=[intel_root]) + assert quote.version == 4 + assert quote.tee_type == 0x81 + assert len(quote.measurement) == 48 + assert quote.measurement != bytes(48)