diff --git a/CHANGELOG.md b/CHANGELOG.md index b80fad4..f2bed48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +- **`docs/hardware-validation.md` listed two runs it documents as "not yet validated".** The live attested peer run and the cross-operator cross-TEE run each have a section in that file, and both were still in the outstanding list below them. The list now names what is actually outstanding: hardware runs of the two new collectors, mutual simultaneous attestation, and operator independence. + ### Added +- **`SevSnpProvider.attest` and `TdxProvider.attest` now produce real evidence.** Both had the defect #74 fixed for TPM: `detect()` returned True wherever the platform's device node existed while `attest()` raised unconditionally, so on a bare-metal SNP or TDX guest the provider was selected and then failed, with an error claiming the platform was absent on a machine that had it. `BaseProvider` states that pair must agree, and two of the three hardware providers broke it. Both now collect through the kernel configfs-TSM interface (`/sys/kernel/config/tsm/report`, Linux 6.7+), which is one interface for both platforms and supersedes the per-platform ioctls, and `detect()` probes what its collector actually needs. + + Detection also missed Azure entirely. Azure runs SEV-SNP behind a Hyper-V paravisor, so the guest sees no `/dev/sev-guest`; on the very CVM this project ran its attested-peer validation on, `SevSnpProvider.detect()` returned False. Azure remains out of scope for this collector, because a paravisor-mediated guest cannot set `REPORT_DATA` at all, but `attest()` now says that instead of reporting a generic absence, and points at the vTPM path that does work there. + + Neither collector has been run on real SEV-SNP or TDX silicon. They are exercised against a simulated configfs tree and synthetic reports, so they are code that should work rather than a validated capability, and `docs/hardware-validation.md` records exactly that. +- **A key-and-nonce binding for SEV-SNP and TDX.** `REPORT_DATA` carries `sha256("ca2a-snp-v1|" || len32(public_key) || public_key || len32(nonce) || nonce)`, zero-padded to the 64-byte field, and TDX the same under `ca2a-tdx-v1|`. Only TPM had one, so the other two platforms had no defined way to commit the offered channel key and their verifiers' `expected_report_data` had nothing to compare against. Each collector confirms the returned report commits the binding it asked for before shipping it. The three prefixes are domain-separated so a report from one platform cannot be replayed as another's evidence. - **`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. - **`AttestationReport` can carry evidence.** Four optional fields (`raw_evidence`, `quote_signature`, `attestation_key_pem`, `attestation_key_chain_pem`), named to match cmcp's model so evidence is portable between the runtimes. Without them a report was `platform`, `measurement`, `public_key` and `nonce` with nothing signed behind it, so a relying party could not verify anything and the hardware tier could not supply verifiable evidence by construction. Absent on `software-only`, which has no evidence. - **The quote commits the offered key, not just the nonce.** `extraData` carries `sha256("ca2a-tpm-v1|" || len32(public_key) || public_key || len32(nonce) || nonce)`, and `ca2a_verify.tpm.verify_tpm_report` re-derives it from the report's own fields and requires equality. That is what promotes `public_key` and `nonce` from assertion to signed fact, so sealing to "a key from a verified report" is actually rooted in hardware; committing the nonce alone would sign for freshness only. Fields are length-prefixed rather than delimiter-joined because a delimiter lets a value containing it shift the split without changing the digest, and `nonce` is an arbitrary caller-supplied string. The returned measurement is read out of the signed quote, and a report whose `measurement` disagrees with it is rejected. @@ -22,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **One derivation for the key-and-nonce binding**, in `ca2a_runtime.tee.binding`, shared by all three providers so the platforms cannot drift apart. `tpm_qualifying_data` keeps its signature and its bytes; only the prefix differs per platform. Conformance requirement ATTEST-001 now states the durable invariant (a provider is never selected where it cannot produce evidence) rather than the temporary fact that SEV-SNP and TDX had no collector. - **TPM quote cryptography now delegates to `agent_manifest.verify_tpm_quote`** instead of being cA2A's own third copy of one verifier (cmcp#447). cA2A keeps only what agent-manifest does not model: `TPMT_SIGNATURE`, the envelope `tpm2_quote -s` and tpm2-pytss `signature.marshal()` emit, unwrapped to the bare signature agent-manifest takes. `verify_tpm_quote` keeps its signature and behavior, including the magic and attest-type checks, which agent-manifest enforces too. - **No SHA-1 PCR fallback and no unsigned-PCR-read tier**, both deliberate departures from cmcp's collector. cmcp downgrades to `software-only` in each case; cA2A raises. A report labelled `sha256:` that measured SHA-1 banks is a mislabel waiting to happen, and a `tpm` report that can never verify is worse than an honest error. The collector also cross-checks its own PCR read against the quote's `pcrDigest`, so a PCR selection mismatch is caught before evidence ships. - Docs corrected where they stated the opposite of the code: `detect()` returning False for every hardware provider was asserted in the attestation spec, the component model, failure modes, and two tutorials. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index b7d66a3..bafa3dc 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 `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. `ca2a start` inherits that: it refuses to start under `provider: auto` when no confidential-computing platform is detected, so a software-mode listener is always a config that names `software-only`, never a downgrade. 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. +- **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* for SEV-SNP and TDX is now implemented, through the kernel configfs-TSM interface, but has **not been run on real silicon**: the collectors are exercised against a simulated configfs tree and synthetic reports only, so they are code that should work rather than a validated capability. Azure confidential VMs are explicitly out of scope for the SEV-SNP collector, since a paravisor-mediated guest cannot set `REPORT_DATA` and roots its channel key through the vTPM instead. 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 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. diff --git a/ROADMAP.md b/ROADMAP.md index b8472a4..eeb019b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,13 +30,13 @@ 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 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`. +- **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 is implemented via configfs-TSM but is not yet hardware-validated, and Azure's paravisor shape is out of scope for it. 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 is implemented via configfs-TSM but is not yet hardware-validated. 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. - **Live attested peer: landed.** The `verifier` seam has been driven off a real SEV-SNP quote on an Azure confidential VM, so `verify_offer` returned `assurance="hardware"` and a payload was sealed to a hardware-vouched channel key; measurement mismatch and stale nonce both rejected. See [docs/hardware-validation.md](docs/hardware-validation.md). - **Cross-operator, cross-TEE run: landed.** An Azure SEV-SNP peer appraised a GCP Intel TDX peer's real quote, sealed a delegated task to the attested key, and the TDX enclave opened it, enforced the attenuated scope, allowed `tool:search` and refused `tool:purchase` with a denial record returned across the boundary. See [docs/hardware-validation.md](docs/hardware-validation.md). -- **Pending:** mutual simultaneous attestation (that run was one-directional) and the TPM certificate-chain path. TPM parsing, bindings and the AK signature are validated against a real Azure vTPM quote; 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`. +- **Pending:** a hardware run of the SEV-SNP and TDX collectors (both implemented against configfs-TSM, neither yet exercised on silicon), mutual simultaneous attestation (that run was one-directional), and the TPM certificate-chain path. TPM parsing, bindings and the AK signature are validated against a real Azure vTPM quote; 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 index 05bfcf2..fda9c77 100644 --- a/docs/hardware-validation.md +++ b/docs/hardware-validation.md @@ -16,6 +16,15 @@ and the run is recorded below. | 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 (Azure vTPM, Trusted Launch) | Yes | Yes, to a caller-supplied vendor root | Yes | **Partly**, 2026-07-27. Parse, bindings and AK signature yes; certificate chain no, see below | +That table is about *appraisal*. Collection is a separate axis, and a verifier validated on real evidence says nothing about whether this codebase can produce that evidence: + +| Platform | Collector | Run on real silicon | +|---|---|---| +| TPM 2.0 | tpm2-pytss, platform AK with a transient fallback | **Yes**, 2026-08-01, Azure Trusted Launch vTPM | +| AMD SEV-SNP (non-paravisor) | configfs-TSM, provider `sev_guest` | **No** | +| AMD SEV-SNP (Azure paravisor) | Out of scope: the guest cannot set `REPORT_DATA`, so the channel key is rooted through the vTPM instead | n/a | +| Intel TDX (non-paravisor) | configfs-TSM, provider `tdx_guest` | **No** | + ## What these runs do and do not establish They establish that the appraisal path in `ca2a_verify` accepts genuine evidence @@ -179,6 +188,16 @@ Trusted Launch VMs actually present, not a discrete TPM chip. - **TPM certificate chain**: needs a quote signed by Azure's pre-provisioned AK (the one its NV certificate covers) plus Microsoft's `Global Virtual TPM CA` intermediate, which is not distributed with the certificate. +- **SEV-SNP collection**: `SevSnpProvider.attest` requests a report over + configfs-TSM and has never run on an SNP guest. It needs a non-paravisor guest + (kernel 6.7+, the `sev-guest` driver registering a TSM provider) and root. The + run should confirm the provider string is `sev_guest`, that the returned + `REPORT_DATA` equals the derived `ca2a-snp-v1` binding, and that the report + verifies to the AMD root, ideally with the VCEK chain arriving in `auxblob`. + Note that the SEV-SNP appraisal above used an Azure capture, and Azure is + precisely where this collector does not apply. +- **TDX collection**: `TdxProvider.attest` likewise. A GCP C3 guest is the + natural host, since the quote appraised above came from one. - **Mutual simultaneous attestation**: in the cross-TEE run above the attestation was one-directional. A appraised B's TDX quote; B's appraisal of A's SNP report was not exercised, and both peers were driven by one operator's harness. Two diff --git a/docs/spec/attestation.md b/docs/spec/attestation.md index 4b5985c..9561eb2 100644 --- a/docs/spec/attestation.md +++ b/docs/spec/attestation.md @@ -9,6 +9,32 @@ A provider implements `BaseProvider`: - `detect()` returns whether the provider is available on the current host. Available means `attest` can actually produce evidence here, not merely that the hardware exists: a provider that returns True and then raises would be selected and then fail. - `attest(public_key, nonce)` returns an `AttestationReport` binding `public_key` to the host's hardware measurement under `nonce`. +### The signed key-and-nonce binding + +Every provider signs over a caller-chosen field: `extraData` on a TPM quote, `REPORT_DATA` on a SEV-SNP report, `REPORTDATA` on a TDX quote. cA2A commits **both** the offered channel public key and the nonce there: + +``` +binding = sha256(prefix || len32(public_key) || public_key || len32(nonce) || nonce) +``` + +| Platform | Prefix | Field | Value written | +|---|---|---|---| +| TPM 2.0 | `ca2a-tpm-v1\|` | `extraData` (32 bytes) | the binding | +| AMD SEV-SNP | `ca2a-snp-v1\|` | `REPORT_DATA` (64 bytes) | the binding, zero-padded on the right | +| Intel TDX | `ca2a-tdx-v1\|` | `REPORTDATA` (64 bytes) | the binding, zero-padded on the right | + +Committing the nonce alone would sign for freshness only, leaving `public_key` an unsigned assertion, so sealing a payload "to a key from a verified report" would not actually be rooted in hardware. A verifier re-derives this value from the report's own fields and requires equality, which is what promotes `public_key` and `nonce` from claim to signed fact. A report whose key was substituted after collection is rejected. + +Three encoding details are load-bearing, and each prefix is versioned because this is wire format: a peer and its verifier MUST derive identical bytes. + +- **Hashed, not raw.** `TPM2B_DATA` is capped below 64 bytes on some platforms (Azure returns `TPM_RC_SIZE`), and 32 bytes always fits. SEV-SNP and TDX reserve 64, so the digest is left-aligned and zero-padded, which is the convention the kernel's own callers and `agent-manifest` use; a report collected by either runtime is then byte-comparable. +- **Length-prefixed, not delimiter-joined.** With a delimiter a value containing it shifts the split without changing the digest, so `("a|b", "c")` and `("a", "b|c")` would commit identical bytes and a peer could bind a key other than the one it appears to offer. `nonce` is an arbitrary caller-supplied string, so that is reachable rather than theoretical. +- **Domain-separated per platform.** The three prefixes differ so a report collected on one platform cannot be replayed as another's evidence. + +`ca2a_runtime.tee.binding` holds the single derivation the three providers share. + +## The report + An `AttestationReport` carries `platform`, `measurement`, the bound `public_key`, and the `nonce`. Those four fields are what a report *claims*; on their own they are an assertion, since any peer can populate them with any values. Four further fields carry the evidence that makes them checkable, and they are named to match cmcp's report model so evidence is portable between the two runtimes: | Field | Contents | @@ -25,8 +51,8 @@ All four are absent on `software-only`, which has no evidence by construction. A | Provider | Platform | Status | |---|---|---| | `software-only` | none | Available; for development and CI. Reports `platform: software-only`, never a hardware platform string. | -| `sev-snp` | AMD SEV-SNP | Verifier implemented (see below). Report generation requires a real SEV-SNP guest. | -| `tdx` | Intel TDX | Verifier implemented (see below). Quote generation requires a real TDX guest. | +| `sev-snp` | AMD SEV-SNP | Verifier and collector both implemented (see below). `attest` produces a real report on a non-paravisor SNP guest through configfs-TSM. | +| `tdx` | Intel TDX | Verifier and collector both implemented (see below). `attest` produces a real DCAP quote on a TDX guest through configfs-TSM. | | `tpm` | TPM 2.0 / vTPM | Verifier and collector both implemented (see below). `attest` produces a real quote on a Linux host with a TPM and tpm2-pytss. | | `opaque` | OPAQUE Confidential Runtime | Tier 3, explicit opt-in, not auto-selected | @@ -38,7 +64,17 @@ All four are absent on `software-only`, which has no evidence by construction. A 2. Report signature: the ECDSA-P384 signature (stored as little-endian `r` and `s`) is verified against the VCEK public key over the report body (`report[:0x2A0]`). 3. Binding: the launch `measurement` and the `report_data` (which carries the runtime key and nonce) are checked against expected values. -What is validated. The chain-verification path is exercised against the genuine AMD Milan ARK/ASK root chain fetched from AMD KDS (`tests/fixtures/sev_snp/`). The report-signature path is exercised end to end with a synthetic VCEK and report, because a genuine report plus VCEK pair requires real SEV-SNP hardware. Producing a report (`SevSnpProvider.attest`) fails closed off hardware (`AttestationUnsupported`). +What is validated. The chain-verification path is exercised against the genuine AMD Milan ARK/ASK root chain fetched from AMD KDS (`tests/fixtures/sev_snp/`). The report-signature path is exercised end to end with a synthetic VCEK and report, because a genuine report plus VCEK pair requires real SEV-SNP hardware. + +### SEV-SNP collection + +`SevSnpProvider.attest` requests a report through the kernel configfs-TSM interface (`/sys/kernel/config/tsm/report`, Linux 6.7+), writing the `ca2a-snp-v1` binding as `REPORT_DATA` and reading the signed report back. It confirms the kernel's reported provider is `sev_guest` and that the returned report commits the binding it asked for, then ships the report as `raw_evidence` with the embedded signature as `quote_signature`. + +No certificate chain travels with an SNP report yet, so `attestation_key_chain_pem` is absent and a verifier fetches the VCEK from the AMD KDS. The kernel does return a certificate table in `auxblob`, but as GUID-tagged DER rather than PEM; parsing a binary layout this collector has never seen from real hardware would be a guess, and shipping it in a PEM-named field would be a wrong one. Parsing it is what would make SNP appraisal fully offline, and is left until there is hardware to check it against. + +**Azure confidential VMs are a different collector, not this one.** Azure runs SNP behind a Hyper-V paravisor, so the guest sees no `/dev/sev-guest`, registers no TSM provider, and cannot set `REPORT_DATA` at all: the paravisor binds the vTPM attestation key there instead. `detect()` therefore returns False on Azure and `attest` says why, rather than reporting that no SEV-SNP guest is present on a machine that is one. Rooting a channel key on Azure goes through the vTPM, which is the `tpm` provider's shape. + +The collector has **not** been run on real SEV-SNP silicon. It is exercised against a simulated configfs tree and synthetic reports in `tests/unit/test_snp_tdx_attest.py`; see [hardware-validation.md](../hardware-validation.md) for what has. Cross-operator use. Two operators in separate trust domains each bind their sealed-channel public key into a report and verify the counterparty's report against a pinned golden measurement. This composes into mutual attestation, confidential cross-operator delegation (seal to the attested key), and binary-swap detection (a changed measurement is rejected), validated in software as claim C6. See the [call graph](call-graph.md) and the `claim6-cross-operator-attestation` experiment. @@ -48,23 +84,21 @@ Cross-operator use. Two operators in separate trust domains each bind their seal What is validated. The chain-verification path accepts the genuine self-signed Intel SGX Root CA fetched from Intel (`tests/fixtures/tdx/`) and rejects an untrusted root. The multi-level signature path (PCK to QE report to attestation key to quote) is exercised end to end with a synthetic self-consistent quote, because a genuine quote requires a TDX guest. Byte offsets follow the Intel DCAP Quote v4 layout; end-to-end validation against a real hardware quote requires a TDX guest and remains open. -## TPM verification +### TDX collection -`ca2a_verify.tpm.verify_tpm_report` appraises a peer's TPM report offline: the AK certificate chain is verified to a trusted root, the AK signature over the attest blob is verified (ECDSA-SHA256 or RSA PKCS#1 v1.5), the structure is confirmed to be a TPM-generated quote (magic and type), and the key-and-nonce binding below is checked. `verify_tpm_quote` is the lower-level form taking an attest blob and a bare signature directly. +`TdxProvider.attest` uses the same configfs-TSM path as SEV-SNP, with the provider name `tdx_guest` and the `ca2a-tdx-v1` binding as `REPORTDATA`. Non-paravisor TDX is guest-controlled, so unlike Azure's SNP the guest sets that field itself, which is what the cross-operator run on GCP C3 relied on. The collector rejects a quote whose TEE type is not TDX or whose `REPORTDATA` does not match the binding it requested. A DCAP quote carries its own signature and PCK chain, so the evidence shipped is the quote verbatim and the chain travels inside it. -The cryptography is not implemented in cA2A. Steps 1, 2 and 4 delegate to `agent_manifest.verify_tpm_quote`, the canonical hardware-validated implementation cA2A already depends on; three divergent copies of one TPM verifier is the problem being retired (cmcp#447). What cA2A keeps is the piece agent-manifest does not model: `TPMT_SIGNATURE`, the envelope `tpm2_quote -s` and tpm2-pytss `signature.marshal()` actually emit, which is unwrapped to the bare signature agent-manifest takes. +The collector has **not** been run on real TDX silicon. It is exercised against a simulated configfs tree and synthetic quotes in `tests/unit/test_snp_tdx_attest.py`. -### The signed binding +## TPM verification -A TPM quote commits caller-chosen bytes in its `extraData` (qualifying data) field. cA2A commits **both** the offered channel public key and the nonce: +`ca2a_verify.tpm.verify_tpm_report` appraises a peer's TPM report offline: the AK certificate chain is verified to a trusted root, the AK signature over the attest blob is verified (ECDSA-SHA256 or RSA PKCS#1 v1.5), the structure is confirmed to be a TPM-generated quote (magic and type), and the key-and-nonce binding below is checked. `verify_tpm_quote` is the lower-level form taking an attest blob and a bare signature directly. -``` -extraData = sha256("ca2a-tpm-v1|" || len32(public_key) || public_key || len32(nonce) || nonce) -``` +The cryptography is not implemented in cA2A. Steps 1, 2 and 4 delegate to `agent_manifest.verify_tpm_quote`, the canonical hardware-validated implementation cA2A already depends on; three divergent copies of one TPM verifier is the problem being retired (cmcp#447). What cA2A keeps is the piece agent-manifest does not model: `TPMT_SIGNATURE`, the envelope `tpm2_quote -s` and tpm2-pytss `signature.marshal()` actually emit, which is unwrapped to the bare signature agent-manifest takes. -Committing the nonce alone would sign for freshness only, leaving `public_key` an unsigned assertion, and sealing a payload "to a key from a verified report" would not actually be rooted in hardware. The verifier re-derives this value from the report's own fields and requires equality, which is what promotes `public_key` and `nonce` from claim to signed fact. A report whose key was substituted after the quote was taken is rejected. +### What the quote measures -Two encoding details are load-bearing. The value is hashed to 32 bytes rather than carried raw because `TPM2B_DATA` is capped below 64 bytes on some platforms (Azure returns `TPM_RC_SIZE`). Each field is length-prefixed rather than delimiter-joined because with a delimiter a value containing it shifts the split without changing the digest, so `("a|b", "c")` and `("a", "b|c")` would commit identical bytes and a peer could bind a key other than the one it appears to offer. `nonce` is an arbitrary caller-supplied string, so that is reachable rather than theoretical. +The binding a TPM quote commits in `extraData` is the shared one defined above, under the `ca2a-tpm-v1|` prefix. The measurement is `sha256:` followed by the quote's own `pcrDigest`, over PCRs 0-7 in the SHA-256 bank. The collector separately reads those PCRs and requires its digest to equal the quoted one, which catches a PCR selection mismatch before evidence ships. The verifier returns the measurement read out of the signed quote rather than the report's `measurement` field, and rejects a report where the two disagree. @@ -84,7 +118,7 @@ Chained verification did **not** pass on that host, because its AK certificate c ## Fail closed -A provider `detect()`s to True only where `attest` works on that host, and verification fails closed when evidence is absent or invalid. `sev-snp` and `tdx` have verifiers but no collector yet, so their `attest` raises `AttestationUnsupported`; `software-only` returns False from `detect` so a no-guarantee posture is always an explicit choice. See [LIMITATIONS.md](../../LIMITATIONS.md). +A provider `detect()`s to True only where `attest` works on that host, and verification fails closed when evidence is absent or invalid. All three hardware providers now implement `attest`, so each `detect` probes what its own collector needs: a TPM device plus tpm2-pytss, or configfs-TSM plus the platform's guest device node. Where a host cannot collect, `attest` raises `AttestationUnsupported` naming the missing piece rather than asserting the platform is absent. `software-only` returns False from `detect` so a no-guarantee posture is always an explicit choice. See [LIMITATIONS.md](../../LIMITATIONS.md). ## Why this is the critical path diff --git a/docs/spec/component-model.md b/docs/spec/component-model.md index c3cfe06..97d2ef0 100644 --- a/docs/spec/component-model.md +++ b/docs/spec/component-model.md @@ -22,7 +22,7 @@ The cA2A runtime is a set of small, composable modules under `src/`. Each maps t ### tee -`ca2a_runtime.tee.base` defines the provider interface and evidence model. `AttestationReport` is a frozen dataclass binding a `public_key` to a `measurement` under a `nonce` on a named `platform`, plus four optional evidence fields (`raw_evidence`, `quote_signature`, `attestation_key_pem`, `attestation_key_chain_pem`) that make those claims checkable. `BaseProvider` is an ABC with `detect()` and `attest(public_key, nonce)`, and the two must agree: `detect()` is True only where `attest()` works. TPM has a collector; SEV-SNP, TDX and OPAQUE have verifiers but no collector yet, so their `attest()` raises and verification fails closed. See [attestation](attestation.md). +`ca2a_runtime.tee.base` defines the provider interface and evidence model. `AttestationReport` is a frozen dataclass binding a `public_key` to a `measurement` under a `nonce` on a named `platform`, plus four optional evidence fields (`raw_evidence`, `quote_signature`, `attestation_key_pem`, `attestation_key_chain_pem`) that make those claims checkable. `BaseProvider` is an ABC with `detect()` and `attest(public_key, nonce)`, and the two must agree: `detect()` is True only where `attest()` works. TPM, SEV-SNP and TDX all have collectors, the latter two through the kernel configfs-TSM interface; OPAQUE has a verifier but no collector, so its `attest()` raises and verification fails closed. See [attestation](attestation.md). ### config diff --git a/docs/spec/error-codes.md b/docs/spec/error-codes.md index f0a92de..4986ff8 100644 --- a/docs/spec/error-codes.md +++ b/docs/spec/error-codes.md @@ -15,7 +15,7 @@ An error also carries a human-readable message and an optional `detail`. The mes | `BrokenDelegationLink` | `BROKEN_DELEGATION_LINK` | 409 | A hop does not chain to its stated parent, or continuity is broken: empty chain, a root credential that names a parent or has nonzero depth, a hop whose parent link or subject does not match the previous hop, or a hop depth that is not previous + 1. | | `DelegationDepthExceeded` | `DELEGATION_DEPTH_EXCEEDED` | 403 | A chain is longer than the configured `max_delegation_depth`. Raised by `verify_chain`. | | `CredentialReplay` | `CREDENTIAL_REPLAY` | 409 | A `credential_id` appears more than once in a single chain. Raised by `verify_chain`. | -| `AttestationUnsupported` | `ATTESTATION_UNSUPPORTED` | 500 | An attestation provider was requested that the host cannot supply. Raised by `SevSnpProvider.attest` off SEV-SNP hardware; also reserved for the TDX/TPM backends. See [Peer Attestation](attestation.md). | +| `AttestationUnsupported` | `ATTESTATION_UNSUPPORTED` | 500 | An attestation provider was requested that the host cannot supply. Raised by any provider's `attest` when the host lacks what its collector needs, and by `OpaqueProvider`, which has no collector. The `detail` names the missing piece. See [Peer Attestation](attestation.md). | | `AttestationFailed` | `ATTESTATION_FAILED` | 412 | Attestation evidence was present but did not verify. Raised by the SEV-SNP verifier on a malformed report, an untrusted or broken certificate chain, a bad report signature, or a measurement / report-data mismatch. See [Peer Attestation](attestation.md). | | `SealedChannelError` | `SEALED_CHANNEL_ERROR` | 500 | The sealed peer channel could not construct or open a payload: an invalid peer public key, a malformed or unsupported sealed blob, a wrong key, or a tampered ciphertext (AEAD authentication failure). Fails closed; never returns unauthenticated plaintext. See [Sealed Channel](sealed-channel.md). | | `ProvenanceLinkBroken` | `PROVENANCE_LINK_BROKEN` | 409 | A `DelegationRecord` does not chain to its stated parent record, or a record was tampered with so its hash no longer matches a child's link: empty provenance chain, duplicate `record_id`, a root record that references a parent, a broken parent hash link, or a record whose `credential_id` or subject does not match the chain. Raised by `verify_dag` and `cross_check_chain`. | @@ -26,7 +26,7 @@ An error also carries a human-readable message and an optional `detail`. The mes `ConfigError`, `InvalidCredential`, `ScopeEscalation`, `BrokenDelegationLink`, `DelegationDepthExceeded`, `CredentialReplay`, and `ProvenanceLinkBroken` are raised by shipping code paths: attenuated delegation, offline chain verification, and the provenance DAG. `ScopeNotPermitted` is raised by the peer-call enforcement decision core (`enforce_peer_call`), and `SealedChannelError` by the sealed channel (`SealedChannel.seal`, `open_sealed`), both of which are implemented. `TransportError` is raised by the A2A metadata adapter when cA2A keys are present but cannot be parsed into a `PeerRequest`. -`AttestationFailed` is raised by the SEV-SNP verifier (chain, report signature, and measurement binding), and `AttestationUnsupported` by `SevSnpProvider.attest` off hardware. Producing a real report requires a SEV-SNP guest, and the TDX/TPM backends are not yet implemented. See [Peer Attestation](attestation.md) and [ROADMAP.md](../../ROADMAP.md). +`AttestationFailed` is raised by the SEV-SNP verifier (chain, report signature, and measurement binding), and by a collector whose hardware returned evidence that does not commit the key and nonce it asked for. `AttestationUnsupported` is raised where a host cannot collect at all: no TPM or tpm2-pytss for `tpm`, no configfs-TSM or guest device for `sev-snp` and `tdx`, and on Azure confidential VMs, where SEV-SNP runs behind a paravisor that owns `REPORT_DATA`. See [Peer Attestation](attestation.md) and [ROADMAP.md](../../ROADMAP.md). ## Handling errors diff --git a/docs/spec/failure-modes.md b/docs/spec/failure-modes.md index 63acd64..81d1d56 100644 --- a/docs/spec/failure-modes.md +++ b/docs/spec/failure-modes.md @@ -104,7 +104,7 @@ Peer attestation proves a peer runs measured code before a task is trusted to it - `ATTESTATION_UNSUPPORTED` (`AttestationUnsupported`): no attestation backend is available for the requested platform. - `ATTESTATION_FAILED` (`AttestationFailed`): a backend ran but the measurement or quote did not verify. -A provider returns `True` from `detect()` only where `attest()` can actually produce evidence on that host, so a provider is never selected and then found broken. `tpm` has a collector: on a Linux host with a TPM and tpm2-pytss it produces a real quote, and `AttestationUnsupported` names the actual missing piece rather than claiming no TPM is present. `sev-snp`, `tdx` and `opaque` have verifiers but no collector yet, so their `attest()` raises. The `software-only` provider returns `False` from `detect()`, so a no-guarantee posture is always an explicit choice, and it never reports a hardware platform string. +A provider returns `True` from `detect()` only where `attest()` can actually produce evidence on that host, so a provider is never selected and then found broken. All three hardware providers have collectors: `tpm` on a Linux host with a TPM and tpm2-pytss, `sev-snp` and `tdx` on a guest exposing the kernel configfs-TSM interface with the matching guest device. Where a host cannot collect, `AttestationUnsupported` names the actual missing piece rather than claiming the platform is absent, including on Azure confidential VMs, where SEV-SNP runs behind a paravisor and the channel key is rooted through the vTPM instead. `opaque` has a verifier but no collector, so its `attest()` raises. The `software-only` provider returns `False` from `detect()`, so a no-guarantee posture is always an explicit choice, and it never reports a hardware platform string. Verification fails closed on absent or invalid evidence. A report claiming a hardware platform with no `raw_evidence` or `quote_signature`, or with no certificate chain for the key that signed it, is rejected rather than trusted. The remaining gap is a full collect-then-verify pass in one process on hardware, which is Tier 3 on the [roadmap](../../ROADMAP.md) and a shared critical path with cmcp. diff --git a/examples/rejection-with-proof/chain.json b/examples/rejection-with-proof/chain.json index 4993281..ed17224 100644 --- a/examples/rejection-with-proof/chain.json +++ b/examples/rejection-with-proof/chain.json @@ -2,8 +2,8 @@ "chain": [ { "credential_id": "cred-0-orchestrator", - "issuer": "5d950cd9fea67de730ac35d540c48d62a22dada5618db0c8474b5eae5562a7cf", - "subject": "2f35acc0747fb45e739288d02bbd18e54e5283f770e30391b1c272fc1285e3bf", + "issuer": "f4e97d6f0f3c0abefa7daa37d9965f1bd96297c7ed1210e2520c3a8841d6c2ff", + "subject": "2e79eaa089777f566934b15ebd93af8f007f6bdad0a70047f7c21079b5771f31", "scope": [ "task:read", "task:write", @@ -12,12 +12,12 @@ ], "depth": 0, "parent_id": null, - "signature": "21056267f494e8ac773bf9aa76b23815baded9b17605c265296f719241050a76b991cbd0162385359302607f19483d81cacc971f8d3cdbf1bc3d8c4cca778d0e" + "signature": "7b36ce1f8b1488b9ad31e4854bfde766d4dab9bb8b1a1ee256d067137527458dbcf26568a7d34db56122a2e7aa862395c61c65bb627daad1b9f901ff999a3800" }, { "credential_id": "cred-1-researcher", - "issuer": "2f35acc0747fb45e739288d02bbd18e54e5283f770e30391b1c272fc1285e3bf", - "subject": "b12a13df30c7123fe6ab8e03551d7c7465cb1acfa04701e0efb05eeba9a2b7d6", + "issuer": "2e79eaa089777f566934b15ebd93af8f007f6bdad0a70047f7c21079b5771f31", + "subject": "cc141e5c3eca4d9fb361e78737c326ae71816a20408fb604414d5af0d7991203", "scope": [ "task:read", "tool:purchase", @@ -25,18 +25,18 @@ ], "depth": 1, "parent_id": "cred-0-orchestrator", - "signature": "308b8c1cab0b49bb1993f185b67515adf832bf50a8cc9ddbf7e9e4d785cac9f3ab20a73be8cef332e28b409fc67a904eef4955ac0b1fcded39734303f0e0bf01" + "signature": "06a4c58f36fc7e1fc5b0190f728301d33b17ef962820c1b501a8bd8f52f0c7182eb10f982f4f3ece858429c158e8d2f3531034577699f170f63de4d47353c70e" }, { "credential_id": "cred-2-retriever", - "issuer": "b12a13df30c7123fe6ab8e03551d7c7465cb1acfa04701e0efb05eeba9a2b7d6", - "subject": "2da412a4c5cd6d9f59cf6bf84fc8d78cb9ba3352c515f85031592cd6a59fb9a2", + "issuer": "cc141e5c3eca4d9fb361e78737c326ae71816a20408fb604414d5af0d7991203", + "subject": "21911b98fd592dd3da353f25080bb93bc668d09e06fc8a5f133875064d73a96b", "scope": [ "tool:search" ], "depth": 2, "parent_id": "cred-1-researcher", - "signature": "6307eb2fb085b2dcca5d5cbdc21a6762c18019db5e8c29714bc7e99837d696d7f18ddbbe60ebfb3c6815f4a44e61020f71a4b30da5d387a3b9afc22ef1b49204" + "signature": "a5f9a7c4311eb1915f1479bd842078f0d61a30772043d406460ab096a2d3eda020a9e55ff9ccf63e702853a796dd156a4311d54861742a3928d15fed0c834c00" } ] } diff --git a/examples/rejection-with-proof/dag.json b/examples/rejection-with-proof/dag.json index 594c7b8..fd5f93c 100644 --- a/examples/rejection-with-proof/dag.json +++ b/examples/rejection-with-proof/dag.json @@ -3,7 +3,7 @@ { "record_id": "rec-0-orchestrator", "credential_id": "cred-0-orchestrator", - "subject": "2f35acc0747fb45e739288d02bbd18e54e5283f770e30391b1c272fc1285e3bf", + "subject": "2e79eaa089777f566934b15ebd93af8f007f6bdad0a70047f7c21079b5771f31", "scope": [ "task:read", "task:write", @@ -15,31 +15,31 @@ { "record_id": "rec-1-researcher", "credential_id": "cred-1-researcher", - "subject": "b12a13df30c7123fe6ab8e03551d7c7465cb1acfa04701e0efb05eeba9a2b7d6", + "subject": "cc141e5c3eca4d9fb361e78737c326ae71816a20408fb604414d5af0d7991203", "scope": [ "task:read", "tool:purchase", "tool:search" ], - "parent_record_hash": "3655a4752c7d0841cf4cc2adb80523ea988469a548d4b05636707132ab25c1b1" + "parent_record_hash": "d7780be8f7359f0a544dc9ce46369f4a02ae91c65c5544149ed28bb42d0bf305" }, { "record_id": "rec-2-retriever", "credential_id": "cred-2-retriever", - "subject": "2da412a4c5cd6d9f59cf6bf84fc8d78cb9ba3352c515f85031592cd6a59fb9a2", + "subject": "21911b98fd592dd3da353f25080bb93bc668d09e06fc8a5f133875064d73a96b", "scope": [ "tool:search" ], - "parent_record_hash": "1b5cc71b77a37400961d71ccc75c4292f5ed122bd77ef854137dda059540faf9" + "parent_record_hash": "3c80a30e428bc0bb55fe53bea23a4f9676ebf4b7ea84d9018670791ddc49acff" }, { "record_id": "rec-denied-purchase", "credential_id": "cred-2-retriever", - "subject": "2da412a4c5cd6d9f59cf6bf84fc8d78cb9ba3352c515f85031592cd6a59fb9a2", + "subject": "21911b98fd592dd3da353f25080bb93bc668d09e06fc8a5f133875064d73a96b", "scope": [ "tool:search" ], - "parent_record_hash": "169191a74d471318788dab7619b3914121763baa8a34b2f4465b7e16ba10398e", + "parent_record_hash": "9064b60d017e4de781a5cdfdc196c5593e77ae4488dd4b91dbfc086d76ab1375", "decision": "deny", "requested_capability": "tool:purchase", "effective_scope": [ diff --git a/src/ca2a_runtime/tee/base.py b/src/ca2a_runtime/tee/base.py index 24dfcc2..5e71654 100644 --- a/src/ca2a_runtime/tee/base.py +++ b/src/ca2a_runtime/tee/base.py @@ -40,10 +40,12 @@ class BaseProvider(ABC): True and then raising is the one combination to avoid, because the provider gets selected and then fails. - :class:`~ca2a_runtime.tee.tpm.TpmProvider` implements ``attest``. SEV-SNP and - TDX do not yet: their collectors are Tier 3 (see ROADMAP.md), so they raise - :class:`~ca2a_runtime.errors.AttestationUnsupported` while their verifiers are - exercised against report vectors. + All three hardware providers implement ``attest``: TPM 2.0 through + tpm2-pytss, SEV-SNP and TDX through the kernel configfs-TSM interface. Each + ``detect`` therefore probes what its ``attest`` actually needs, and where a + host cannot collect at all, ``attest`` raises + :class:`~ca2a_runtime.errors.AttestationUnsupported` naming the missing piece + rather than reporting a generic absence of the platform. """ platform: str = "base" diff --git a/src/ca2a_runtime/tee/binding.py b/src/ca2a_runtime/tee/binding.py new file mode 100644 index 0000000..24987fb --- /dev/null +++ b/src/ca2a_runtime/tee/binding.py @@ -0,0 +1,52 @@ +"""The key-and-nonce binding a cA2A attestation report commits to. + +Every hardware provider signs over a field the caller chooses: ``extraData`` on a +TPM quote, ``REPORT_DATA`` on a SEV-SNP report, ``REPORTDATA`` on a TDX quote. +What cA2A puts there is a digest over *both* the offered channel public key and +the caller's nonce, so one signature covers freshness and which key is being +offered. Commit the nonce alone and a report's ``public_key`` stays an unsigned +assertion, which would leave sealing to "a key from a verified report" not +actually rooted in hardware. + +The derivation is shared so the three platforms cannot drift apart. Only the +domain-separation prefix differs, and each is versioned because this is wire +format: a peer and its verifier must derive the same bytes independently. See +``docs/spec/attestation.md``. +""" + +from __future__ import annotations + +import hashlib + +TPM_PREFIX = b"ca2a-tpm-v1|" +SNP_PREFIX = b"ca2a-snp-v1|" +TDX_PREFIX = b"ca2a-tdx-v1|" + + +def derive_binding(prefix: bytes, public_key: str, nonce: str) -> bytes: + """Return the 32-byte digest committing ``public_key`` and ``nonce``. + + Each field is length-prefixed rather than separated by a delimiter. With a + delimiter, a value containing it moves the split without changing the digest: + ``("a|b", "c")`` and ``("a", "b|c")`` would commit identical bytes, so a peer + could bind a key other than the one it appears to offer. ``nonce`` is an + arbitrary caller-supplied string, so that is reachable rather than theoretical. + """ + parts = [] + for field in (public_key.encode(), nonce.encode()): + parts.append(len(field).to_bytes(4, "big")) + parts.append(field) + return hashlib.sha256(prefix + b"".join(parts)).digest() + + +def pad_report_data(digest: bytes, length: int) -> bytes: + """Left-align ``digest`` in a ``length``-byte field, zero-filling the rest. + + SEV-SNP and TDX both reserve 64 bytes where a TPM allows 32, and neither + defines a layout for a shorter value. Left-aligned and zero-padded is the + convention the kernel's own callers and ``agent-manifest`` use, so a report + collected here is byte-comparable with one collected by the sibling runtime. + """ + if len(digest) > length: + raise ValueError(f"binding digest is {len(digest)} bytes, exceeds the {length}-byte field") + return digest + bytes(length - len(digest)) diff --git a/src/ca2a_runtime/tee/sev_snp.py b/src/ca2a_runtime/tee/sev_snp.py index 4cd0049..55148e3 100644 --- a/src/ca2a_runtime/tee/sev_snp.py +++ b/src/ca2a_runtime/tee/sev_snp.py @@ -11,20 +11,46 @@ stays as cA2A's surface, keeping its error contract and the ``signature_rs`` helper, but the bytes are read by the shared parser. -Producing a report requires a real SEV-SNP guest (``/dev/sev-guest``), so -:meth:`SevSnpProvider.attest` fails closed off hardware. The verifier does not -need hardware and is exercised against the real AMD root certificate chain plus -synthetic report vectors in the test suite. +:meth:`SevSnpProvider.attest` produces a real report through the kernel +configfs-TSM interface (see :mod:`ca2a_runtime.tee.tsm`), on the guests where +that is possible: a non-paravisor SNP guest whose ``sev-guest`` driver has +registered a TSM provider. What it binds is the point. ``REPORT_DATA`` commits +:func:`snp_report_data`, a digest over *both* the channel public key and the +caller's nonce, so a caller sealing to the key in a verified report is sealing to +a key the AMD PSP signed for. + +Azure confidential VMs are deliberately not this provider. Azure runs SNP behind +a Hyper-V paravisor, so the guest sees no ``/dev/sev-guest``, registers no TSM +provider, and cannot set ``REPORT_DATA`` at all: the paravisor binds the vTPM +attestation key there instead. That path reads the report from a vTPM NV index +and roots the channel key through a TPM quote, which is +:class:`~ca2a_runtime.tee.tpm.TpmProvider`'s shape rather than this one. Saying +so is why :meth:`detect` returns False there instead of selecting a provider that +would then fail. + +The verifier does not need hardware and is exercised against the real AMD root +certificate chain plus synthetic report vectors in the test suite. """ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from agent_manifest import SNP_OFFSETS, SNP_REPORT_LEN, SnpVerificationError, parse_snp_report from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported from ca2a_runtime.tee.base import AttestationReport, BaseProvider +from ca2a_runtime.tee.binding import SNP_PREFIX, derive_binding, pad_report_data +from ca2a_runtime.tee.tsm import ( + PROVIDER_SEV_GUEST, + collect_report, + require_tsm, + tsm_available, +) +from ca2a_runtime.tee.tsm import ( + REPORT_DATA_LEN as TSM_REPORT_DATA_LEN, +) # Layout of the SEV-SNP ATTESTATION_REPORT, re-exported from agent-manifest's # shared ABI table so cA2A and cmcp cannot disagree about where a field sits. @@ -40,6 +66,22 @@ SEV_GUEST_DEVICE = "/dev/sev-guest" +# The measurement is a 48-byte launch digest, so it is labelled as SHA-384 for +# the same reason the TPM report labels its PCR digest: a bare hex string does +# not say what produced it. +MEASUREMENT_DIGEST_LABEL = "sha384" + + +def snp_report_data(public_key: str, nonce: str) -> bytes: + """Return the 64 bytes a cA2A SEV-SNP report commits in ``REPORT_DATA``. + + The 32-byte binding digest, zero-padded to the field width. See + :mod:`ca2a_runtime.tee.binding` and ``docs/spec/attestation.md``. + """ + return pad_report_data( + derive_binding(SNP_PREFIX, public_key, nonce), TSM_REPORT_DATA_LEN + ) + @dataclass(frozen=True) class SevSnpReport: @@ -93,18 +135,73 @@ def parse(cls, blob: bytes) -> SevSnpReport: class SevSnpProvider(BaseProvider): - """AMD SEV-SNP provider. Report generation requires a real SEV-SNP guest.""" + """AMD SEV-SNP provider. Produces a real report via configfs-TSM. + + ``detect`` reports True only where ``attest`` can actually run: Linux, the + configfs-TSM interface, and the ``sev-guest`` device node whose driver + registers the TSM provider. Both signals are required because the provider + name is only readable from inside an entry, which needs root, so the device + node stands in for "this is an SNP guest" at detection time and ``attest`` + confirms it against the kernel before returning any bytes. + """ platform = "sev-snp" @classmethod def detect(cls) -> bool: - import os - - return os.path.exists(SEV_GUEST_DEVICE) + if not tsm_available(): + return False + return Path(SEV_GUEST_DEVICE).exists() def attest(self, public_key: str, nonce: str) -> AttestationReport: - raise AttestationUnsupported( - "SEV-SNP report generation requires a real SEV-SNP guest", - detail=f"{SEV_GUEST_DEVICE} not present; run on an AMD SEV-SNP confidential VM", + """Request an SNP report committing ``public_key`` and ``nonce``. + + Raises :class:`AttestationUnsupported` when this host cannot produce a + report at all, and :class:`AttestationFailed` when it can but the attempt + did not yield verifiable evidence. + """ + self._require_host() + + expected = snp_report_data(public_key, nonce) + # auxblob carries AMD's certificate table (VCEK/ASK/ARK as GUID-tagged DER + # blobs), not PEM. Passing it through as attestation_key_chain_pem would + # assert a format this collector has never seen from real hardware, so it + # is dropped rather than guessed at; a verifier fetches the VCEK from the + # AMD KDS. Parsing it is what would make SNP appraisal fully offline. + outblob, _auxblob = collect_report(expected, expect_provider=PROVIDER_SEV_GUEST) + report = SevSnpReport.parse(outblob) + + # The PSP signed whatever it was given. Confirming the round trip here + # means a report that reached the wire commits the key this call offered, + # rather than one a concurrent caller wrote into a shared entry. + if report.report_data != expected: + raise AttestationFailed( + "the report does not commit the requested key and nonce binding", + detail="REPORT_DATA does not match the derived binding", + ) + + return AttestationReport( + platform=self.platform, + measurement=f"{MEASUREMENT_DIGEST_LABEL}:{report.measurement.hex()}", + public_key=public_key, + nonce=nonce, + raw_evidence=report.raw, + # The SNP signature is carried inside the report body rather than + # alongside it, so this is a slice of raw_evidence, not a second blob. + quote_signature=report.raw[SIG_OFFSET : SIG_OFFSET + 2 * SIG_COMPONENT_LEN], ) + + @classmethod + def _require_host(cls) -> None: + """Fail with the actual reason, never a generic "no SEV-SNP guest".""" + require_tsm("SEV-SNP") + if not Path(SEV_GUEST_DEVICE).exists(): + raise AttestationUnsupported( + "SEV-SNP report generation requires a non-paravisor SNP guest", + detail=( + f"configfs-TSM is present but {SEV_GUEST_DEVICE} is not. On an Azure " + "confidential VM this is expected: SNP runs behind a Hyper-V " + "paravisor, the guest cannot set REPORT_DATA, and the report is read " + "from a vTPM NV index instead. Use the tpm provider there." + ), + ) diff --git a/src/ca2a_runtime/tee/tdx.py b/src/ca2a_runtime/tee/tdx.py index 607fed4..9c3a9dd 100644 --- a/src/ca2a_runtime/tee/tdx.py +++ b/src/ca2a_runtime/tee/tdx.py @@ -6,23 +6,41 @@ PCK signature, and the PCK certificate chain). Verification lives in :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, 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``. +:meth:`TdxProvider.attest` produces a real quote through the kernel configfs-TSM +interface (see :mod:`ca2a_runtime.tee.tsm`). Non-paravisor TDX is +guest-controlled, so unlike Azure's SEV-SNP the guest sets ``REPORTDATA`` +directly: it commits :func:`tdx_report_data`, a digest over *both* the channel +public key and the caller's nonce, so a caller sealing to the key in a verified +quote is sealing to a key the TDX module signed for. + +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 import struct from dataclasses import dataclass +from pathlib import Path from cryptography import x509 +from cryptography.hazmat.primitives.serialization import Encoding from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported from ca2a_runtime.tee.base import AttestationReport, BaseProvider +from ca2a_runtime.tee.binding import TDX_PREFIX, derive_binding, pad_report_data +from ca2a_runtime.tee.tsm import ( + PROVIDER_TDX_GUEST, + collect_report, + require_tsm, + tsm_available, +) +from ca2a_runtime.tee.tsm import ( + REPORT_DATA_LEN as TSM_REPORT_DATA_LEN, +) HEADER_LEN = 48 TD_REPORT_LEN = 584 @@ -45,6 +63,20 @@ CERT_DATA_HEADER_LEN = 6 TDX_GUEST_DEVICE = "/dev/tdx_guest" +# MRTD is a 48-byte launch digest; the label says what produced it. +MEASUREMENT_DIGEST_LABEL = "sha384" + + +def tdx_report_data(public_key: str, nonce: str) -> bytes: + """Return the 64 bytes a cA2A TDX quote commits in ``REPORTDATA``. + + The 32-byte binding digest, zero-padded to the field width. See + :mod:`ca2a_runtime.tee.binding` and ``docs/spec/attestation.md``. + """ + return pad_report_data( + derive_binding(TDX_PREFIX, public_key, nonce), TSM_REPORT_DATA_LEN + ) + @dataclass(frozen=True) class TdxQuote: @@ -137,18 +169,73 @@ def parse(cls, blob: bytes) -> TdxQuote: class TdxProvider(BaseProvider): - """Intel TDX provider. Quote generation requires a real TDX guest.""" + """Intel TDX provider. Produces a real DCAP quote via configfs-TSM. + + ``detect`` reports True only where ``attest`` can actually run: Linux, the + configfs-TSM interface, and the ``tdx_guest`` device node whose driver + registers the TSM provider. Both signals are required because the provider + name is only readable from inside an entry, which needs root, so the device + node stands in for "this is a TDX guest" at detection time and ``attest`` + confirms it against the kernel before returning any bytes. + """ platform = "tdx" @classmethod def detect(cls) -> bool: - import os - - return os.path.exists(TDX_GUEST_DEVICE) + if not tsm_available(): + return False + return Path(TDX_GUEST_DEVICE).exists() def attest(self, public_key: str, nonce: str) -> AttestationReport: - raise AttestationUnsupported( - "TDX quote generation requires a real TDX guest", - detail=f"{TDX_GUEST_DEVICE} not present; run on an Intel TDX confidential VM", + """Request a TDX quote committing ``public_key`` and ``nonce``. + + Raises :class:`AttestationUnsupported` when this host cannot produce a + quote at all, and :class:`AttestationFailed` when it can but the attempt + did not yield verifiable evidence. + """ + self._require_host() + + expected = tdx_report_data(public_key, nonce) + outblob, _auxblob = collect_report(expected, expect_provider=PROVIDER_TDX_GUEST) + quote = TdxQuote.parse(outblob) + + if quote.tee_type != TEE_TYPE_TDX: + raise AttestationFailed( + "the quote is not a TDX quote", + detail=f"tee_type={quote.tee_type:#x}, expected {TEE_TYPE_TDX:#x}", + ) + if quote.report_data != expected: + raise AttestationFailed( + "the quote does not commit the requested key and nonce binding", + detail="REPORTDATA does not match the derived binding", + ) + + # The PCK chain arrives inside the quote, so the evidence is already + # self-contained; auxblob would only repeat it. + chain_pem = b"".join(cert.public_bytes(Encoding.PEM) for cert in quote.pck_chain) + + return AttestationReport( + platform=self.platform, + measurement=f"{MEASUREMENT_DIGEST_LABEL}:{quote.measurement.hex()}", + public_key=public_key, + nonce=nonce, + # A TDX quote carries its own signature and PCK chain, so the + # evidence a peer ships is the quote verbatim. + raw_evidence=outblob, + quote_signature=quote.quote_signature, + attestation_key_chain_pem=chain_pem or None, ) + + @classmethod + def _require_host(cls) -> None: + """Fail with the actual reason, never a generic "no TDX guest".""" + require_tsm("TDX") + if not Path(TDX_GUEST_DEVICE).exists(): + raise AttestationUnsupported( + "TDX quote generation requires a non-paravisor TDX guest", + detail=( + f"configfs-TSM is present but {TDX_GUEST_DEVICE} is not, so the " + "tdx_guest driver has not registered a TSM provider on this host" + ), + ) diff --git a/src/ca2a_runtime/tee/tpm.py b/src/ca2a_runtime/tee/tpm.py index af833b3..45ad8d6 100644 --- a/src/ca2a_runtime/tee/tpm.py +++ b/src/ca2a_runtime/tee/tpm.py @@ -42,6 +42,7 @@ from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported from ca2a_runtime.tee.base import AttestationReport, BaseProvider +from ca2a_runtime.tee.binding import TPM_PREFIX, derive_binding logger = logging.getLogger(__name__) @@ -59,7 +60,7 @@ # Domain-separated binding committed into the quote's extraData. Versioned # because it is wire format: a peer and its verifier must derive it identically. # See docs/spec/attestation.md. -_QUALIFYING_DATA_PREFIX = b"ca2a-tpm-v1|" +_QUALIFYING_DATA_PREFIX = TPM_PREFIX # Platforms that provision an attestation key expose it at a persistent handle # with a certificate in NV. Azure Trusted Launch uses these two; both are @@ -91,17 +92,10 @@ def tpm_qualifying_data(public_key: str, nonce: str) -> bytes: Hashed rather than concatenated raw because ``TPM2B_DATA`` is capped below 64 bytes on some platforms (Azure returns ``TPM_RC_SIZE``); 32 bytes always fits. - Each field is length-prefixed rather than separated by a delimiter. With a - delimiter, a value containing it moves the split without changing the digest: - ``("a|b", "c")`` and ``("a", "b|c")`` would commit identical bytes, so a peer - could bind a key other than the one it appears to offer. ``nonce`` is an - arbitrary caller-supplied string, so that is reachable rather than theoretical. + The derivation itself is :func:`ca2a_runtime.tee.binding.derive_binding`, + shared with the SEV-SNP and TDX bindings so the three cannot drift apart. """ - parts = [] - for field in (public_key.encode(), nonce.encode()): - parts.append(len(field).to_bytes(4, "big")) - parts.append(field) - return hashlib.sha256(_QUALIFYING_DATA_PREFIX + b"".join(parts)).digest() + return derive_binding(_QUALIFYING_DATA_PREFIX, public_key, nonce) def _read_u16(buf: bytes, pos: int) -> tuple[int, int]: diff --git a/src/ca2a_runtime/tee/tsm.py b/src/ca2a_runtime/tee/tsm.py new file mode 100644 index 0000000..cb9a920 --- /dev/null +++ b/src/ca2a_runtime/tee/tsm.py @@ -0,0 +1,132 @@ +"""Kernel configfs-TSM access, the collection path SEV-SNP and TDX share. + +Linux 6.7 added one in-kernel interface for confidential-guest attestation: a +caller creates a directory under ``/sys/kernel/config/tsm/report``, writes up to +64 bytes to ``inblob``, and reads the platform's signed report back from +``outblob``. The same interface serves AMD SEV-SNP (provider ``sev_guest``) and +Intel TDX (provider ``tdx_guest``), which is why this module is shared rather +than copied into each provider. + +It supersedes the per-platform ioctls on ``/dev/sev-guest`` and +``/dev/tdx_guest``. Those device nodes still matter, as a *platform* signal +rather than a collection path: the driver that creates one is the driver that +registers the TSM provider, and the provider name is only readable from inside an +entry, which needs root. Detection therefore pairs the two signals and +:func:`collect_report` confirms the provider name before returning any bytes. + +Not covered here: Azure confidential VMs. Azure runs SEV-SNP behind a Hyper-V +paravisor, so the guest never sees ``/dev/sev-guest``, registers no TSM provider, +and cannot choose ``REPORT_DATA`` at all. That path reads the report from a vTPM +NV index and is a different collector; the providers here say so rather than +reporting a generic absence. +""" + +from __future__ import annotations + +import contextlib +import os +import secrets +import sys +from pathlib import Path + +from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported + +TSM_REPORT_DIR = "/sys/kernel/config/tsm/report" + +# Both platforms reserve 64 bytes for caller-chosen report data. +REPORT_DATA_LEN = 64 + +PROVIDER_SEV_GUEST = "sev_guest" +PROVIDER_TDX_GUEST = "tdx_guest" + + +def tsm_available() -> bool: + """True when this host exposes the configfs-TSM report interface.""" + return sys.platform == "linux" and Path(TSM_REPORT_DIR).is_dir() + + +def require_tsm(platform: str) -> None: + """Raise :class:`AttestationUnsupported` naming what is actually missing.""" + if sys.platform != "linux": + raise AttestationUnsupported( + f"{platform} report generation is only implemented on Linux", + detail=f"running on {sys.platform}; configfs-TSM is a Linux interface", + ) + if not Path(TSM_REPORT_DIR).is_dir(): + raise AttestationUnsupported( + f"{platform} report generation requires the configfs-TSM interface", + detail=( + f"{TSM_REPORT_DIR} is not present; it needs kernel 6.7+ with a registered " + "TSM provider, and configfs mounted" + ), + ) + + +def collect_report(report_data: bytes, *, expect_provider: str) -> tuple[bytes, bytes | None]: + """Return ``(outblob, auxblob)`` for ``report_data`` from the TSM provider. + + ``auxblob`` is the certificate material the provider supplies alongside the + report, when it supplies any, and is ``None`` otherwise. Shipping it with the + evidence is what lets a relying party verify offline instead of fetching a + chain at appraisal time. + + The entry name is unique per call. A fixed name looks harmless but is a race: + two processes collecting at once would share one entry, and the second write + to ``inblob`` would change the report the first is about to read, so a peer + could ship a report committing someone else's key. + """ + if len(report_data) > REPORT_DATA_LEN: + raise AttestationFailed( + "report data is larger than the field the platform reserves", + detail=f"got {len(report_data)} bytes, the limit is {REPORT_DATA_LEN}", + ) + + entry = Path(TSM_REPORT_DIR) / f"ca2a-{os.getpid()}-{secrets.token_hex(4)}" + try: + entry.mkdir() + except OSError as exc: + raise AttestationUnsupported( + "the kernel refused a configfs-TSM report entry", + detail=( + f"could not create {entry}: {exc}. Creating an entry needs root and a " + "registered TSM provider; a guest whose driver registered none is not " + "a supported collection host." + ), + ) from exc + + try: + try: + (entry / "inblob").write_bytes(report_data) + outblob = (entry / "outblob").read_bytes() + provider = (entry / "provider").read_text().strip() + except OSError as exc: + raise AttestationFailed( + "the configfs-TSM provider did not return a report", + detail=f"{type(exc).__name__}: {exc}", + ) from exc + + if provider != expect_provider: + raise AttestationFailed( + "the configfs-TSM provider is not the expected platform", + detail=( + f"the kernel reports {provider!r}, this collector expects " + f"{expect_provider!r}; the wrong provider was selected for this host" + ), + ) + if not outblob: + raise AttestationFailed( + "the configfs-TSM provider returned an empty report", + detail=f"provider={provider!r} produced no bytes in outblob", + ) + + try: + auxblob = (entry / "auxblob").read_bytes() or None + except OSError: + # Optional: several providers ship no certificate material at all. + auxblob = None + return outblob, auxblob + finally: + # Leaving an entry behind is untidy but not a failure of the report that + # was already read, and each call uses a fresh name anyway. + with contextlib.suppress(OSError): + entry.rmdir() diff --git a/tests/conformance/README.md b/tests/conformance/README.md index cf03ec8..50c8026 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -52,7 +52,7 @@ Spec: [attestation.md](../../docs/spec/attestation.md) | ID | Level | Requirement | Expected outcome | |---|---|---|---| -| ATTEST-001 | MUST | Hardware providers without a backend are never auto-selected. | `detect()` returns False; generating a report fails closed with `ATTESTATION_UNSUPPORTED`. | +| ATTEST-001 | MUST | A hardware provider is never selected on a host where it cannot produce evidence. | `detect()` returns False wherever collection is impossible, and `attest()` fails closed with `ATTESTATION_UNSUPPORTED` naming the missing piece rather than the platform. | | ATTEST-002 | MUST | An attestation report whose measurement differs from the expected value is rejected. | `ATTESTATION_FAILED`. | | ATTEST-003 | MUST | A report whose certificate chain does not reach a trusted root is rejected. | `ATTESTATION_FAILED`. | | ATTEST-004 | MUST | A report with a tampered body or signature is rejected. | `ATTESTATION_FAILED`. | diff --git a/tests/conformance/test_profile_conformance.py b/tests/conformance/test_profile_conformance.py index 09fd14a..15c9180 100644 --- a/tests/conformance/test_profile_conformance.py +++ b/tests/conformance/test_profile_conformance.py @@ -192,12 +192,18 @@ def test_policy_003_allowed_not_delegated_denied() -> None: # --- Group 3: Attestation --- def test_attest_001_providers_fail_closed() -> None: + """Both providers can collect on the right guest; this host is not one. + + The pair must agree. A provider that reported True here and then raised + would be selected and then fail, and the error must name what is missing + rather than asserting the platform is absent. + """ assert SevSnpProvider.detect() is False assert TdxProvider.detect() is False - with pytest.raises(AttestationUnsupported): - SevSnpProvider().attest("deadbeef", "n") - with pytest.raises(AttestationUnsupported): - TdxProvider().attest("deadbeef", "n") + for provider in (SevSnpProvider(), TdxProvider()): + with pytest.raises(AttestationUnsupported) as excinfo: + provider.attest("deadbeef", "n") + assert excinfo.value.detail def _sev_setup(): diff --git a/tests/unit/test_sev_snp.py b/tests/unit/test_sev_snp.py index 0bf3ee5..3759e95 100644 --- a/tests/unit/test_sev_snp.py +++ b/tests/unit/test_sev_snp.py @@ -155,7 +155,11 @@ def test_real_amd_untrusted_root_rejected() -> None: def test_provider_detect_and_attest() -> None: - assert SevSnpProvider.detect() is False # no /dev/sev-guest in this environment + """No configfs-TSM in this environment, so the pair agrees on "cannot collect". + + The collector itself is exercised in test_snp_tdx_attest.py. + """ + assert SevSnpProvider.detect() is False with pytest.raises(AttestationUnsupported): SevSnpProvider().attest("deadbeef", "nonce") diff --git a/tests/unit/test_snp_tdx_attest.py b/tests/unit/test_snp_tdx_attest.py new file mode 100644 index 0000000..00d00ac --- /dev/null +++ b/tests/unit/test_snp_tdx_attest.py @@ -0,0 +1,470 @@ +"""Tests for the SEV-SNP and TDX collectors and the binding they commit. + +Three halves, loosely: + +- the key-and-nonce binding, including that the three platforms derive different + bytes from the same inputs and that a field boundary cannot be shifted; +- the configfs-TSM collection path, driven against a simulated configfs tree so + it runs without a confidential guest. Creating an entry materialises its + attributes and writing ``inblob`` is what makes ``outblob`` readable, which is + the kernel's actual sequence; +- each provider's ``detect``/``attest`` pair, including that they agree. + +Synthetic vectors, not hardware. Neither collector has been run on real SEV-SNP +or TDX silicon; docs/hardware-validation.md records what has. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec + +from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported +from ca2a_runtime.tee import tsm +from ca2a_runtime.tee.binding import ( + SNP_PREFIX, + TDX_PREFIX, + TPM_PREFIX, + derive_binding, + pad_report_data, +) +from ca2a_runtime.tee.sev_snp import SevSnpProvider, snp_report_data +from ca2a_runtime.tee.tdx import TdxProvider, tdx_report_data +from tests.unit.conftest import make_sev_snp_report +from tests.unit.test_tdx import build_quote + +PUBLIC_KEY = "aa" * 32 +NONCE = "deadbeef" + + +# ── the signed binding ──────────────────────────────────────────────────────── + + +def test_binding_is_32_bytes() -> None: + assert len(derive_binding(SNP_PREFIX, PUBLIC_KEY, NONCE)) == 32 + + +def test_each_platform_commits_different_bytes() -> None: + """Domain separation: one report must not be replayable as another platform's.""" + digests = { + derive_binding(prefix, PUBLIC_KEY, NONCE) + for prefix in (TPM_PREFIX, SNP_PREFIX, TDX_PREFIX) + } + assert len(digests) == 3 + + +def test_a_field_boundary_cannot_be_shifted() -> None: + """The reason fields are length-prefixed rather than delimiter-joined. + + With a delimiter, these two would commit identical bytes and a peer could + bind a key other than the one it appears to offer. + """ + assert derive_binding(SNP_PREFIX, "a|b", "c") != derive_binding(SNP_PREFIX, "a", "b|c") + + +@pytest.mark.parametrize("report_data", [snp_report_data, tdx_report_data]) +def test_report_data_is_the_padded_binding(report_data) -> None: + value = report_data(PUBLIC_KEY, NONCE) + assert len(value) == 64 + assert value[32:] == bytes(32) + assert value[:32] != bytes(32) + + +def test_padding_refuses_a_digest_wider_than_the_field() -> None: + with pytest.raises(ValueError, match="exceeds"): + pad_report_data(b"\x01" * 65, 64) + + +# ── configfs-TSM ────────────────────────────────────────────────────────────── + + +def install_fake_tsm( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + provider: str, + make_outblob, + auxblob: bytes | None = None, +) -> list[Path]: + """Simulate the kernel's configfs-TSM report interface under ``tmp_path``. + + Returns the list of entry directories created, so a test can assert on how + the interface was driven rather than only on what came back. + """ + root = tmp_path / "tsm-report" + root.mkdir() + monkeypatch.setattr(tsm, "TSM_REPORT_DIR", str(root)) + monkeypatch.setattr("ca2a_runtime.tee.tsm.sys.platform", "linux") + + entries: list[Path] = [] + real_mkdir = Path.mkdir + real_write_bytes = Path.write_bytes + + def mkdir(self: Path, *args, **kwargs): # noqa: ANN002, ANN003, ANN202 + result = real_mkdir(self, *args, **kwargs) + if self.parent == root: + entries.append(self) + return result + + def write_bytes(self: Path, data: bytes) -> int: + written = real_write_bytes(self, data) + if self.name == "inblob": + entry = self.parent + real_write_bytes(entry / "outblob", make_outblob(data)) + (entry / "provider").write_text(provider + "\n") + if auxblob is not None: + real_write_bytes(entry / "auxblob", auxblob) + return written + + monkeypatch.setattr(Path, "mkdir", mkdir) + monkeypatch.setattr(Path, "write_bytes", write_bytes) + return entries + + +def test_collect_report_returns_the_report_and_its_certificates( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_fake_tsm( + monkeypatch, + tmp_path, + provider=tsm.PROVIDER_SEV_GUEST, + make_outblob=lambda data: b"report:" + data[:4], + auxblob=b"certs", + ) + outblob, auxblob = tsm.collect_report(b"\x01" * 64, expect_provider=tsm.PROVIDER_SEV_GUEST) + assert outblob == b"report:" + b"\x01" * 4 + assert auxblob == b"certs" + + +def test_collect_report_reports_no_certificates_when_none_are_supplied( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_fake_tsm( + monkeypatch, + tmp_path, + provider=tsm.PROVIDER_TDX_GUEST, + make_outblob=lambda _data: b"quote", + ) + _outblob, auxblob = tsm.collect_report(b"\x00" * 64, expect_provider=tsm.PROVIDER_TDX_GUEST) + assert auxblob is None + + +def test_collect_report_refuses_the_wrong_platform( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A TDX guest answering an SNP collector is a misconfiguration, not evidence.""" + install_fake_tsm( + monkeypatch, + tmp_path, + provider=tsm.PROVIDER_TDX_GUEST, + make_outblob=lambda _data: b"quote", + ) + with pytest.raises(AttestationFailed, match="not the expected platform"): + tsm.collect_report(b"\x00" * 64, expect_provider=tsm.PROVIDER_SEV_GUEST) + + +def test_collect_report_refuses_an_empty_report( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_fake_tsm( + monkeypatch, + tmp_path, + provider=tsm.PROVIDER_SEV_GUEST, + make_outblob=lambda _data: b"", + ) + with pytest.raises(AttestationFailed, match="empty report"): + tsm.collect_report(b"\x00" * 64, expect_provider=tsm.PROVIDER_SEV_GUEST) + + +def test_collect_report_refuses_oversized_report_data( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_fake_tsm( + monkeypatch, + tmp_path, + provider=tsm.PROVIDER_SEV_GUEST, + make_outblob=lambda _data: b"report", + ) + with pytest.raises(AttestationFailed, match="larger than the field"): + tsm.collect_report(b"\x00" * 65, expect_provider=tsm.PROVIDER_SEV_GUEST) + + +def test_each_collection_uses_its_own_entry( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A shared entry is a race: a concurrent write would change the report. + + Two processes collecting into one fixed entry means the second ``inblob`` + write moves the report the first is about to read, so a peer could ship a + report committing someone else's key. + """ + entries = install_fake_tsm( + monkeypatch, + tmp_path, + provider=tsm.PROVIDER_SEV_GUEST, + make_outblob=lambda data: b"report:" + data[:2], + ) + tsm.collect_report(b"\x01" * 64, expect_provider=tsm.PROVIDER_SEV_GUEST) + tsm.collect_report(b"\x02" * 64, expect_provider=tsm.PROVIDER_SEV_GUEST) + assert len({entry.name for entry in entries}) == 2 + + +def test_collect_report_when_the_kernel_refuses_an_entry( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Creating an entry needs root and a registered provider; say which is missing.""" + root = tmp_path / "tsm-report" + root.mkdir() + monkeypatch.setattr(tsm, "TSM_REPORT_DIR", str(root)) + + def refuse(self: Path, *args, **kwargs): # noqa: ANN002, ANN003, ANN202 + raise PermissionError("Operation not permitted") + + monkeypatch.setattr(Path, "mkdir", refuse) + with pytest.raises(AttestationUnsupported, match="refused a configfs-TSM report entry"): + tsm.collect_report(b"\x00" * 64, expect_provider=tsm.PROVIDER_SEV_GUEST) + + +def test_collect_report_when_the_provider_returns_nothing_readable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """An entry that exists but produces no outblob is a failure, not empty evidence.""" + root = tmp_path / "tsm-report" + root.mkdir() + monkeypatch.setattr(tsm, "TSM_REPORT_DIR", str(root)) + with pytest.raises(AttestationFailed, match="did not return a report"): + tsm.collect_report(b"\x00" * 64, expect_provider=tsm.PROVIDER_SEV_GUEST) + + +def test_require_tsm_names_the_missing_interface( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(tsm, "TSM_REPORT_DIR", str(tmp_path / "absent")) + monkeypatch.setattr("ca2a_runtime.tee.tsm.sys.platform", "linux") + with pytest.raises(AttestationUnsupported, match="configfs-TSM"): + tsm.require_tsm("SEV-SNP") + + +def test_require_tsm_says_so_off_linux(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("ca2a_runtime.tee.tsm.sys.platform", "darwin") + with pytest.raises(AttestationUnsupported, match="only implemented on Linux"): + tsm.require_tsm("TDX") + + +# ── SEV-SNP ─────────────────────────────────────────────────────────────────── + + +def _snp_host( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *, measurement: bytes = b"\x11" * 48 +) -> None: + """A host that looks like a non-paravisor SNP guest, answering with a report.""" + vcek_key = ec.generate_private_key(ec.SECP384R1()) + install_fake_tsm( + monkeypatch, + tmp_path, + provider=tsm.PROVIDER_SEV_GUEST, + make_outblob=lambda data: make_sev_snp_report( + vcek_key, measurement=measurement, report_data=data + ), + ) + device = tmp_path / "sev-guest" + device.touch() + monkeypatch.setattr("ca2a_runtime.tee.sev_snp.SEV_GUEST_DEVICE", str(device)) + + +def test_snp_detect_is_false_without_the_tsm_interface( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(tsm, "TSM_REPORT_DIR", str(tmp_path / "absent")) + assert SevSnpProvider.detect() is False + + +def test_snp_detect_is_false_on_the_azure_paravisor_shape( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Azure SNP has no sev-guest node, so this collector cannot run there.""" + install_fake_tsm( + monkeypatch, tmp_path, provider=tsm.PROVIDER_SEV_GUEST, make_outblob=lambda _d: b"x" + ) + monkeypatch.setattr("ca2a_runtime.tee.sev_snp.SEV_GUEST_DEVICE", str(tmp_path / "absent")) + assert SevSnpProvider.detect() is False + + +def test_snp_detect_is_true_when_attest_can_run( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _snp_host(monkeypatch, tmp_path) + assert SevSnpProvider.detect() is True + + +def test_snp_attest_commits_the_offered_key_and_nonce( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _snp_host(monkeypatch, tmp_path, measurement=b"\x33" * 48) + report = SevSnpProvider().attest(PUBLIC_KEY, NONCE) + + assert report.platform == "sev-snp" + assert report.measurement == "sha384:" + (b"\x33" * 48).hex() + assert report.public_key == PUBLIC_KEY + assert report.nonce == NONCE + assert report.raw_evidence is not None + # The signature is a slice of the report body, not a separate blob. + assert report.quote_signature is not None + assert report.quote_signature in report.raw_evidence + + +def test_snp_attest_refuses_a_report_committing_something_else( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """What a concurrent writer into a shared entry would look like from here.""" + vcek_key = ec.generate_private_key(ec.SECP384R1()) + install_fake_tsm( + monkeypatch, + tmp_path, + provider=tsm.PROVIDER_SEV_GUEST, + make_outblob=lambda _data: make_sev_snp_report( + vcek_key, measurement=b"\x11" * 48, report_data=b"\x99" * 64 + ), + ) + device = tmp_path / "sev-guest" + device.touch() + monkeypatch.setattr("ca2a_runtime.tee.sev_snp.SEV_GUEST_DEVICE", str(device)) + + with pytest.raises(AttestationFailed, match="key and nonce binding"): + SevSnpProvider().attest(PUBLIC_KEY, NONCE) + + +def test_snp_attest_explains_the_azure_paravisor_case( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Not "no SEV-SNP guest" on a machine that is one: name the real reason.""" + install_fake_tsm( + monkeypatch, tmp_path, provider=tsm.PROVIDER_SEV_GUEST, make_outblob=lambda _d: b"x" + ) + monkeypatch.setattr("ca2a_runtime.tee.sev_snp.SEV_GUEST_DEVICE", str(tmp_path / "absent")) + + with pytest.raises(AttestationUnsupported) as excinfo: + SevSnpProvider().attest(PUBLIC_KEY, NONCE) + assert "paravisor" in str(excinfo.value.detail) + + +# ── TDX ─────────────────────────────────────────────────────────────────────── + + +def _tdx_host( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + mrtd: bytes = b"\x11" * 48, + report_data: bytes | None = None, +) -> None: + """A host that looks like a non-paravisor TDX guest, answering with a quote.""" + root_key = ec.generate_private_key(ec.SECP256R1()) + + def make_outblob(data: bytes) -> bytes: + quote, _root = build_quote(mrtd, report_data or data, root_key=root_key) + return quote + + install_fake_tsm( + monkeypatch, tmp_path, provider=tsm.PROVIDER_TDX_GUEST, make_outblob=make_outblob + ) + device = tmp_path / "tdx_guest" + device.touch() + monkeypatch.setattr("ca2a_runtime.tee.tdx.TDX_GUEST_DEVICE", str(device)) + + +def test_tdx_detect_is_false_without_the_tsm_interface( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(tsm, "TSM_REPORT_DIR", str(tmp_path / "absent")) + assert TdxProvider.detect() is False + + +def test_tdx_detect_is_true_when_attest_can_run( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _tdx_host(monkeypatch, tmp_path) + assert TdxProvider.detect() is True + + +def test_tdx_attest_commits_the_offered_key_and_nonce( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _tdx_host(monkeypatch, tmp_path, mrtd=b"\x44" * 48) + report = TdxProvider().attest(PUBLIC_KEY, NONCE) + + assert report.platform == "tdx" + assert report.measurement == "sha384:" + (b"\x44" * 48).hex() + assert report.public_key == PUBLIC_KEY + assert report.nonce == NONCE + # A TDX quote is self-contained, so the evidence is the quote verbatim and + # the PCK chain travels with it. + assert report.raw_evidence is not None + assert report.attestation_key_chain_pem is not None + assert b"BEGIN CERTIFICATE" in report.attestation_key_chain_pem + + +def test_tdx_attest_refuses_a_quote_committing_something_else( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _tdx_host(monkeypatch, tmp_path, report_data=b"\x99" * 64) + with pytest.raises(AttestationFailed, match="key and nonce binding"): + TdxProvider().attest(PUBLIC_KEY, NONCE) + + +def test_tdx_attest_refuses_a_quote_that_is_not_tdx( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The tdx_guest provider answering with another TEE type is not TDX evidence.""" + root_key = ec.generate_private_key(ec.SECP256R1()) + + def make_outblob(data: bytes) -> bytes: + quote, _root = build_quote(b"\x11" * 48, data, root_key=root_key) + # tee_type sits in the header at offset 4. + tampered = bytearray(quote) + tampered[4:8] = (0x00).to_bytes(4, "little") + return bytes(tampered) + + install_fake_tsm( + monkeypatch, tmp_path, provider=tsm.PROVIDER_TDX_GUEST, make_outblob=make_outblob + ) + device = tmp_path / "tdx_guest" + device.touch() + monkeypatch.setattr("ca2a_runtime.tee.tdx.TDX_GUEST_DEVICE", str(device)) + + with pytest.raises(AttestationFailed, match="not a TDX quote"): + TdxProvider().attest(PUBLIC_KEY, NONCE) + + +def test_tdx_attest_names_the_missing_device( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_fake_tsm( + monkeypatch, tmp_path, provider=tsm.PROVIDER_TDX_GUEST, make_outblob=lambda _d: b"x" + ) + monkeypatch.setattr("ca2a_runtime.tee.tdx.TDX_GUEST_DEVICE", str(tmp_path / "absent")) + with pytest.raises(AttestationUnsupported, match="non-paravisor TDX guest"): + TdxProvider().attest(PUBLIC_KEY, NONCE) + + +# ── the contract detect and attest share ────────────────────────────────────── + + +@pytest.mark.parametrize( + ("provider_cls", "install_host"), + [(SevSnpProvider, _snp_host), (TdxProvider, _tdx_host)], +) +def test_detect_and_attest_agree( + provider_cls, install_host, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The invariant in BaseProvider, and the defect #74 fixed for TPM. + + Returning True from detect and then raising ATTESTATION_UNSUPPORTED means the + provider is selected and then fails, with an error claiming the platform is + absent on a machine that has it. + """ + install_host(monkeypatch, tmp_path) + assert provider_cls.detect() is True + provider_cls().attest(PUBLIC_KEY, NONCE) diff --git a/tests/unit/test_tdx.py b/tests/unit/test_tdx.py index ffc5af7..e18fca4 100644 --- a/tests/unit/test_tdx.py +++ b/tests/unit/test_tdx.py @@ -150,6 +150,10 @@ def test_real_intel_root_accepted_and_stranger_rejected() -> None: def test_provider_detect_and_attest() -> None: + """No configfs-TSM in this environment, so the pair agrees on "cannot collect". + + The collector itself is exercised in test_snp_tdx_attest.py. + """ assert TdxProvider.detect() is False with pytest.raises(AttestationUnsupported): TdxProvider().attest("deadbeef", "nonce")