diff --git a/CHANGELOG.md b/CHANGELOG.md index 0296b0f..cbb7f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Corrected the Azure vTPM trust-anchor claim after measuring it on hardware (2026-08-01).** `ca2a_verify/tpm_roots.py` presented `AZURE_VTPM_ROOT_2023_PEM` as "the one root cA2A has validated on hardware", which does not hold fleet-wide. On a `Standard_D2s_v7` in eastus2 the AK certificate at NV `0x01C101D0` is 994 bytes, is issued by `CN=Global Virtual TPM CA - 03`, and carries **no AIA extension**, so no intermediates can be fetched (and none are stored elsewhere in NV), no chain reaches the pinned root, and `verify_tpm_report` fails closed with "AK chain root is not among the supplied trusted TPM roots". A different host (`Standard_D2s_v5`, eastus) presented a 1596-byte certificate under `Azure Cloud Virtual TPM CA - 11` whose AIA chain does reach that root. Both are real: Azure runs more than one vTPM CA generation. The constant stays, now documented as one observed hierarchy rather than a guarantee, and a deployment must pin the hierarchy its own hosts present. `LIMITATIONS.md` and the attestation spec say so too. + + The same run **retires** the caveat that collector and verifier could not run in one process: building `tpm2-pytss` from source inside a venv resolves the conflict with `agent-manifest`'s `cryptography`. What blocks end-to-end verification on that host is the missing certificate chain, not tooling. + ### Added - **`TpmProvider.attest` now produces a real TPM quote.** Previously `detect()` returned True on any host with a TPM device node while `attest()` raised unconditionally, so on every Azure Trusted Launch VM and most modern client hardware the provider was selected and then failed, with an error that claimed no TPM was present when one was (#73). The collector is ported from cmcp's hardware-validated path: it prefers the platform attestation key at persistent handle `0x81000003` with its certificate chunk-read from NV `0x01C101D0` (a single read of a 1596-byte certificate fails with `TPM_RC_VALUE`, because `TPM2_NV_Read` is bounded by `TPM2_PT_NV_BUFFER_MAX`), assembles the chain by walking each certificate's AIA extension so verification stays offline later, and falls back to a transient restricted signing key where no certified platform key exists. `detect()` now returns True only where `attest()` can actually run, and `AttestationUnsupported` names the piece that is actually missing. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 8b181bb..cc31ace 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -13,8 +13,10 @@ cA2A is a pre-release profile in active design. This document states plainly wha - **Hardware-attested live binding.** The `verifier` seam in `ca2a_runtime.attestation` has now been driven off a real SEV-SNP quote on an Azure confidential VM: `verify_offer` returned `assurance="hardware"`, a payload was sealed to a channel key a hardware-verified measurement vouches for, and both a measurement mismatch and a stale nonce were rejected. See [docs/hardware-validation.md](docs/hardware-validation.md). Two gaps remain. First, the reference server/client still run in **software mode** by default (`assurance="none"`); the hardware path is a validated capability, not the default configuration, and there is no CLI listener (`ca2a start`) since serving is via `ca2a_runtime.transport.server.serve`. Second, attestation on that run was one-directional: a follow-on cross-operator run (an Azure SEV-SNP peer calling a GCP Intel TDX peer, recorded in the same document) had the caller appraise the callee's real TDX quote before sealing, but the callee did not appraise the caller in return. Mutual simultaneous attestation is the remaining step, and both peers were driven by one operator's harness. - **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 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, and the key-and-nonce binding) is implemented, and its cryptography is delegated to `agent_manifest.verify_tpm_quote` rather than being a third copy. Quote *generation* still requires the respective hardware for SEV-SNP and TDX. This validates those verifiers, 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. -- **TPM collect-then-verify in one process.** `TpmProvider.attest` now produces a real quote (platform attestation key at a persistent handle with its certificate chunk-read from NV, AIA chain assembly, transient-key fallback), and `ca2a_verify.tpm.verify_tpm_report` appraises it. Both halves are exercised against synthetic self-consistent vectors, and a genuine Azure Trusted Launch vTPM quote parses and verifies under `CA2A_TPM_FIXTURE_DIR`. What has **not** been demonstrated is one process collecting and then verifying on hardware: on the Azure test VM, installing `agent-manifest` conflicts with the distribution's `tpm2_pytss` (older `cryptography`), and a venv fixes the install but then breaks the `tpm2_pytss` import, so the two could not run together there. Treat the TPM tier as validated in halves, not end to end. -- **TPM evidence proves key provenance only where a vendor root exists.** A quote signed by the transient fallback key is a verifiable signature but carries no certificate chain, so it proves nothing about *where* the key lives, and `verify_tpm_report` rejects it. Provenance therefore requires a platform-provisioned certified attestation key. Azure Trusted Launch provides one; a GCP Shielded VM does not (probed 2026-07-31: no EK certificate, no persistent handles, and `get-shielded-identity` returns a bare `ekPub` with no certificate). Client firmware TPM vendor roots are not yet published in a pinnable form. Separately, the TCG event log is 0 bytes on both Azure and GCP, so PCR values cannot be attributed to specific boot events on either cloud. +- **TPM collection works on hardware; chained verification does not, on every host.** Measured on a real Azure Trusted Launch vTPM (`Standard_D2s_v7`, eastus2, 2026-08-01). What passed: `TpmProvider.attest` produced a genuine quote signed by the platform attestation key (RSASSA/SHA-256), the shipped certificate certifies the quoting key, `parse_tpmt_signature` unwrapped the real `TPMT_SIGNATURE` and the bare signature verified against the shipped key, a tampered attest blob was rejected, and the quote's `extraData` equalled the derived key-and-nonce binding. Collector and verifier also ran **in one process**, which the previous caveat here said was untested: building `tpm2-pytss` from source inside a venv resolves the conflict with `agent-manifest`'s `cryptography`, so that tooling limitation is retired. + + What **failed**, and it is the important half: `verify_tpm_report` could not chain to a pinned root, reporting "AK chain root is not among the supplied trusted TPM roots". On that host the AK certificate at NV `0x01C101D0` is 994 bytes, is issued by `CN=Global Virtual TPM CA - 03`, and carries **no AIA extension at all**, so there are no intermediates to fetch and none stored elsewhere in NV. A different Azure host (`Standard_D2s_v5`, eastus, 2026-07-31) presented a 1596-byte certificate under `Azure Cloud Virtual TPM CA - 11` with a walkable AIA chain reaching the root pinned in `ca2a_verify/tpm_roots.py`. Both observations are real: Azure runs more than one vTPM CA generation, so **the shipped Azure root is not sufficient fleet-wide** and a deployment must pin the hierarchy its own hosts actually present. Until then, treat the TPM tier as: evidence is genuine and its signature and binding are verifiable, but key provenance is host-dependent. +- **TPM evidence proves key provenance only where a chain reaches a pinnable root.** A quote signed by the transient fallback key is a verifiable signature but carries no certificate chain, so it proves nothing about *where* the key lives, and `verify_tpm_report` rejects it. As above, a platform-provisioned certified attestation key is necessary but not sufficient: the chain must also be assemblable, which fails when the AK certificate carries no AIA. A GCP Shielded VM is weaker still (probed 2026-07-31: no EK certificate, no persistent handles, and `get-shielded-identity` returns a bare `ekPub` with no certificate). Client firmware TPM vendor roots are not yet published in a pinnable form. Separately, the TCG event log is 0 bytes on both Azure and GCP, so PCR values cannot be attributed to specific boot events on either cloud. ## Out of scope diff --git a/docs/spec/attestation.md b/docs/spec/attestation.md index 6cd41ff..4b5985c 100644 --- a/docs/spec/attestation.md +++ b/docs/spec/attestation.md @@ -70,13 +70,17 @@ The measurement is `sha256:` followed by the quote's own `pcrDigest`, over PCRs ### Trust anchors -TPM attestation keys chain to per-vendor roots, not to one published root the way SEV-SNP and TDX do. cA2A does not decide which vendors a deployment trusts: the verifier takes caller-supplied roots and consults nothing implicitly. `ca2a_verify.tpm_roots.AZURE_VTPM_ROOT_2023_PEM` is the one root validated against hardware, available so a deployment on that platform need not re-derive it, but trusting it stays an explicit import. Supplying no root at all is refused, because a chain validated against no anchor would accept any self-consistent chain. +TPM attestation keys chain to per-vendor roots, not to one published root the way SEV-SNP and TDX do. cA2A does not decide which vendors a deployment trusts: the verifier takes caller-supplied roots and consults nothing implicitly. `ca2a_verify.tpm_roots.AZURE_VTPM_ROOT_2023_PEM` is a root observed on Azure Trusted Launch, available so a deployment on that platform need not re-derive it, but trusting it stays an explicit import. Supplying no root at all is refused, because a chain validated against no anchor would accept any self-consistent chain. + +**Pinning a root is not enough on Azure.** Hardware measurement on 2026-08-01 found the AK certificate presentation varies across the fleet. One host (`Standard_D2s_v5`, eastus) presented a 1596-byte certificate under `Azure Cloud Virtual TPM CA - 11` with a walkable AIA chain reaching the pinned root. Another (`Standard_D2s_v7`, eastus2) presented a 994-byte certificate issued by `Global Virtual TPM CA - 03` with **no AIA extension**, so no intermediates could be fetched, none were stored in NV, and chained verification was impossible. A deployment must obtain and pin the root for the hierarchy its own hosts present. See [LIMITATIONS.md](../../LIMITATIONS.md). ### Two deliberate differences from cmcp's collector cmcp falls back to the SHA-1 PCR bank and downgrades the report to `software-only`; cA2A requires the SHA-256 bank and raises instead, because a report labelled `sha256:` that measured SHA-1 banks is a mislabel waiting to happen. cmcp can also emit a report whose only evidence is an unsigned PCR read, marked software-only; in cA2A, failing to produce a signed quote raises, because the platform string on a cA2A report is the provider's identity and a `tpm` report that can never verify is worse than an honest error. -What is validated. The collector's checks, the TPM interaction shapes, and report verification end to end are exercised against synthetic self-consistent vectors in `tests/unit/test_tpm_attest.py`. A quote from a genuine Azure Trusted Launch vTPM parses and verifies under `CA2A_TPM_FIXTURE_DIR`. What has **not** been demonstrated is a full collect-then-verify pass in one process on hardware: on the Azure test VM, installing `agent-manifest` conflicts with the distribution's `tpm2_pytss`, so the collector and the verifier could not be run together there. See [LIMITATIONS.md](../../LIMITATIONS.md). +What is validated. The collector's checks, the TPM interaction shapes, and report verification end to end are exercised against synthetic self-consistent vectors in `tests/unit/test_tpm_attest.py`. On a real Azure Trusted Launch vTPM (2026-08-01) the collector produced a genuine platform-AK quote, `parse_tpmt_signature` unwrapped the real `TPMT_SIGNATURE` (RSASSA/SHA-256) and the bare signature verified against the shipped key, a tampered attest blob was rejected, and the quote's `extraData` equalled the derived key-and-nonce binding. Collector and verifier ran in one process, which retires the earlier tooling caveat. + +Chained verification did **not** pass on that host, because its AK certificate carries no AIA extension and so no chain can be assembled. That is a property of the host, not a defect in the verifier, and it is recorded in [LIMITATIONS.md](../../LIMITATIONS.md). ## Fail closed diff --git a/src/ca2a_verify/tpm_roots.py b/src/ca2a_verify/tpm_roots.py index b1538bc..b435317 100644 --- a/src/ca2a_verify/tpm_roots.py +++ b/src/ca2a_verify/tpm_roots.py @@ -5,15 +5,22 @@ deployment trusts: :func:`ca2a_verify.tpm.verify_tpm_report` takes the roots the caller supplies, and nothing here is consulted implicitly. -What this module provides is the one root cA2A has actually validated against -hardware, so a deployment on that platform does not have to re-derive it. Trusting -it stays an explicit import: +What this module provides is a root observed on Azure Trusted Launch, so a +deployment on that platform does not have to re-derive it. Trusting it stays an +explicit import: from ca2a_verify.tpm_roots import AZURE_VTPM_ROOT_2023_PEM A chain that validates to an unpinned root is not evidence, because an attacker who can present any self-consistent chain would pass. So passing no roots at all is refused rather than treated as "trust anything". + +**This root is not sufficient on every Azure Trusted Launch host.** Measured on +hardware 2026-08-01 (see the fleet-variance note below): the attestation key +certificate presentation varies across the Azure fleet, and on a host whose AK +certificate carries no AIA extension there is no way to obtain the intermediates, +so no chain reaches this root and verification fails closed. Do not read the +presence of this constant as a promise that Azure verifies out of the box. """ from __future__ import annotations @@ -28,9 +35,28 @@ # CN=Azure Cloud Virtual TPM CA 2025 # CN=Azure Virtual TPM Root Certificate Authority 2023 <- this cert # -# The intermediates are fetchable over the certificate AIA extension, so only the -# self-signed root is pinned here. This is the same anchor cmcp pins in -# cmcp_verify/tpm_roots.py; the two were validated against the same chain. +# On that host the intermediates were fetchable over the certificate AIA extension, +# so only the self-signed root is pinned here. This is the same anchor cmcp pins in +# cmcp_verify/tpm_roots.py. +# +# FLEET VARIANCE, measured 2026-08-01 on a Standard_D2s_v7 in eastus2. The same NV +# index presented a materially different certificate: +# +# size 994 bytes, not 1596 +# subject CN=.TrustedVM.Azure.windows.net (same shape) +# issuer CN=Global Virtual TPM CA - 03 (a DIFFERENT hierarchy) +# AIA absent entirely +# +# With no AIA there is nothing to walk, so the collector ships the leaf alone, and +# no chain can reach the root below. `tpm2_getcap handles-nv-index` showed only +# 0x01C101D0, so the intermediates are not stored elsewhere in NV either. Chained +# verification on that host is therefore impossible with this anchor, and +# verify_tpm_report fails with "AK chain root is not among the supplied trusted +# TPM roots" - correctly, since it cannot prove key provenance. +# +# The consequence for a deployment: obtain and pin the root for the hierarchy your +# own hosts present, rather than assuming this one. Both observations are real; +# Azure runs more than one vTPM CA generation. AZURE_VTPM_ROOT_2023_PEM = b"""\ -----BEGIN CERTIFICATE----- MIIFsDCCA5igAwIBAgIQUfQx2iySCIpOKeDZKd5KpzANBgkqhkiG9w0BAQwFADBp