Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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.
- **`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.
- **`ca2a_verify.tpm.tpm_verifier(roots)`** returns a `Verifier` for `verify_offer`, so a TPM peer reaches `assurance="hardware"`. `ca2a_verify.tpm_roots.AZURE_VTPM_ROOT_2023_PEM` carries the one root validated on hardware as an opt-in constant; nothing is trusted implicitly, and supplying no root is refused rather than treated as trust-anything.

### Changed

- **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.

### Changed

- **BREAKING: TRACE records now carry the v0.2 profile** `tag:agentrust-io.com,2026:trace-v0.2`. Pins move to `agentrust-trace>=0.5` and `agentrust-trace-tests>=0.4,<0.5`, which have to move together: the conformance suite cut over rather than dual-accepting, so 0.4.0 of the suite fails a v0.1 record and 0.3.x fails a v0.2 one. The v0.1 URI named `agentrust.io`, a domain this project never controlled, which RFC 4151 does not permit for a tag URI (agentrust-io/trace-spec#107). Nothing else about the record format changed.
Expand Down
4 changes: 3 additions & 1 deletion LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ 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, qualifying-data and PCR-digest binding) is implemented but synthetic-vector validated only. Quote *generation* still requires the respective hardware for all three. This validates the verifier, not a running attested peer: until the `verifier` seam in `ca2a_runtime.attestation` is driven off a live quote on a confidential VM, cA2A must not be described as attested across trust domains.
- **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.

## Out of scope

Expand Down
45 changes: 39 additions & 6 deletions docs/spec/attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@ Before a peer is trusted with a delegated task, it proves it is running attested

A provider implements `BaseProvider`:

- `detect()` returns whether the provider is available on the current host.
- `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`.

An `AttestationReport` carries `platform`, `measurement`, the bound `public_key`, and the `nonce`.
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 |
|---|---|
| `raw_evidence` | the raw blob the hardware signed (for TPM, the bare `TPMS_ATTEST`) |
| `quote_signature` | the signature over `raw_evidence` (for TPM, a marshalled `TPMT_SIGNATURE`) |
| `attestation_key_pem` | the key that produced that signature |
| `attestation_key_chain_pem` | the leaf-first certificate chain for that key |

All four are absent on `software-only`, which has no evidence by construction. A report claiming a hardware platform with no evidence cannot be verified, so it fails closed rather than being trusted.

## Providers

Expand All @@ -18,7 +27,7 @@ An `AttestationReport` carries `platform`, `measurement`, the bound `public_key`
| `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. |
| `tpm` | TPM 2.0 / vTPM | Verifier implemented (see below). Quote generation requires a real TPM. |
| `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 |

## SEV-SNP verification
Expand All @@ -41,13 +50,37 @@ What is validated. The chain-verification path accepts the genuine self-signed I

## TPM verification

`ca2a_verify.tpm.verify_tpm_quote` appraises a TPM 2.0 quote (`TPMS_ATTEST`) 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 qualifying data (the verifier's nonce) and the PCR digest (the platform measurement) are checked against expected values.
`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.

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 signed binding

A TPM quote commits caller-chosen bytes in its `extraData` (qualifying data) field. cA2A commits **both** the offered channel public key and the nonce:

```
extraData = sha256("ca2a-tpm-v1|" || len32(public_key) || public_key || len32(nonce) || nonce)
```

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.

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 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.

### 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.

### 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. Unlike SEV-SNP and TDX, TPM attestation keys chain to per-vendor EK roots, so there is no single published root to validate against; the caller supplies the vendor roots it trusts, and the verifier is exercised against synthetic self-consistent vectors. Producing a quote (`TpmProvider.attest`) fails closed off a real TPM.
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).

## Fail closed

Providers without a backend `detect()` to False, so they are never selected automatically, and verification fails closed when evidence is absent or invalid. This is deliberate: cA2A must not be described as attested across trust domains until a backend verifies a quote against a golden measurement on real hardware. 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. `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).

## Why this is the critical path

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/component-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. `BaseProvider` is an ABC with `detect()` and `attest(public_key, nonce)`. Real hardware providers (TPM, SEV-SNP, TDX, OPAQUE) are Tier 3 and not implemented; their `detect()` returns False so they are never auto-selected, 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 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).

### config

Expand Down
Loading