Skip to content

Commit effb97e

Browse files
feat(ca2a): appraise the caller before opening its payload
The reference transport was one-directional. The callee verified the caller's delegation chain, which says what a peer is allowed to ask for, and had no way to know whether the peer sending it a task was an enclave or a laptop. The handshake response now carries a callee-issued challenge, the caller binds its own channel key into a report under it (caller_offer in the A2A metadata), and the callee appraises that report before it opens the sealed payload. The ordering is the property, not an implementation detail: the payload is sealed to the callee's own key, so appraising afterwards would mean an unattested caller had already had its work done. The test for it was verified by making the swap and watching it fail. Off by default, opt-in one rung at a time (require_caller_attestation: none -> any -> hardware), because almost no caller can attest yet and a callee that refused them out of the box is a callee nobody can talk to. An offer that is present and does not appraise is refused at every rung including "none": demanding nothing means accepting a caller that proves nothing, not accepting a broken proof. BREAKING: caller_attestation is in the hashed record body, always, so every record hash changes. Absence would leave an auditor unable to tell a peer that checked and found nothing from a peer that never checked. The example DAGs are regenerated. Also closes two gaps found on the way: the 1 MiB request-body bound had no test above it, and the committed example DAGs were never verified as committed (the demos regenerate them, so the suite was checking its own output -- which is how the record-body change left cross-operator/dag.json broken on disk with everything still green). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c5a5969 commit effb97e

23 files changed

Lines changed: 1307 additions & 69 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **The callee now appraises the caller, not just its authority (mutual attestation).** The reference transport was one-directional: the callee verified the caller's delegation chain, which says what a peer is *allowed to ask for*, and had no way to know whether the peer sending it a task was an enclave or a laptop. The handshake response now carries a callee-issued challenge, the caller binds its own channel key into a report under it (`caller_offer` in the A2A metadata), and the callee appraises that report **before it opens the sealed payload**. That ordering is the property: the payload is sealed to the callee's own key, so appraising afterwards would mean an unattested caller had already had its work done. `tests/unit/test_mutual_attestation.py` fails if the two calls are swapped, verified by making the swap.
13+
14+
Off by default and opt-in one rung at a time (`require_caller_attestation`: `"none"``"any"``"hardware"`), because almost no caller can attest yet and a callee that refused them out of the box is a callee nobody can talk to. `"hardware"` without a verifier is refused at construction rather than on every call. An offer that is *present and does not appraise* is refused at every rung including `"none"`: demanding nothing means accepting a caller that proves nothing, not accepting a broken proof, otherwise a misconfigured attestation path is indistinguishable from a caller that never had one. See `docs/spec/mutual-attestation.md`.
15+
16+
- **A test above the 1 MiB request-body bound.** `_MAX_BODY` was declared and documented in the reference server and no test had ever crossed it. It now declares an oversized `Content-Length` over a raw socket, which is what the guard actually inspects: the server refuses before reading, so the oversized body is never buffered.
17+
18+
- **The committed example DAGs are verified as committed.** The demos regenerate `chain.json` / `dag.json` when they run, so the existing example tests were verifying whatever the demo had just written rather than what is in the repository. `tests/unit/test_committed_examples_verify.py` reads the blobs out of git instead. The gap was not hypothetical: the record-body change below left `examples/cross-operator-delegation/dag.json` broken on disk with the whole suite still green and the README still quoting `ca2a verify-dag` as working.
19+
20+
### Changed
21+
22+
- **BREAKING: every provenance record hash changed.** `DelegationRecord` carries `caller_attestation` in its **hashed body**, always, as one of `not_offered`, `failed`, `software-only`, `hardware`. It is deliberately not omitted when nothing was appraised: absence would leave an auditor unable to tell a peer that checked and found nothing from a peer that never checked, which is the reading the field exists to prevent. Four values rather than three because a peer that offered nothing and a peer whose offer did not appraise are different facts. Records written before this field are read as `not_offered`, the only thing their emitter could honestly have claimed, and the example DAGs were regenerated. `ca2a verify-dag` prints `leaf_caller_attestation` on every run, including when it is `not_offered`.
23+
1024
### Fixed
1125

1226
- **Corrected the Azure vTPM trust-anchor claim after measuring it on hardware (2026-08-01).** `ca2a_verify/tpm_roots.py` presented `AZURE_VTPM_ROOT_2023_PEM` as "the one root cA2A has validated on hardware", which does not hold fleet-wide. On a `Standard_D2s_v7` in eastus2 the AK certificate at NV `0x01C101D0` is 994 bytes, is issued by `CN=Global Virtual TPM CA - 03`, and carries **no AIA extension**, so no intermediates can be fetched (and none are stored elsewhere in NV), no chain reaches the pinned root, and `verify_tpm_report` fails closed with "AK chain root is not among the supplied trusted TPM roots". A different host (`Standard_D2s_v5`, eastus) presented a 1596-byte certificate under `Azure Cloud Virtual TPM CA - 11` whose AIA chain does reach that root. Both are real: Azure runs more than one vTPM CA generation. The constant stays, now documented as one observed hierarchy rather than a guarantee, and a deployment must pin the hierarchy its own hosts present. `LIMITATIONS.md` and the attestation spec say so too.

LIMITATIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ cA2A is a pre-release profile in active design. This document states plainly wha
1010

1111
## What is stubbed or not yet implemented
1212

13-
- **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.
13+
- **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. The *protocol* is no longer one-directional -- a callee now issues a challenge and appraises the caller's offer before opening the sealed payload ([docs/spec/mutual-attestation.md](docs/spec/mutual-attestation.md)) -- but that is implemented and tested in **software mode only**, it is off by default, and making the protocol mutual does not make the recorded hardware run mutual. Mutual attestation on real silicon in both directions is still outstanding, it is still not *simultaneous* (the caller commits a sealed payload before the callee has appraised it), and both peers were driven by one operator's harness.
1414
- **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.
1515
- **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.
1616
- **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.

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via
3636
- **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.
3737
- **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).
3838
- **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).
39-
- **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`.
39+
- **Pending:** a hardware run of the SEV-SNP and TDX collectors (both implemented against configfs-TSM, neither yet exercised on silicon), mutual attestation on real silicon in both directions (the protocol now supports it in software mode and is off by default; that hardware run was one-directional), simultaneous attestation (which needs a commitment step neither peer can back out of, a larger protocol than what landed), 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`.
4040

4141
## v1.0: Stable profile
4242

docs/spec/mutual-attestation.md

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
# Mutual Attestation
22

33
---
4-
Status: Proposal
4+
Status: Implemented in the reference transport
55
Written: 2026-08-09
66
Stability: Unstable
77
---
88

9+
> **State of this document.** The design below is built. The callee issues a
10+
> challenge on the handshake endpoint, the caller binds its own channel key into a
11+
> report under it, and the callee appraises that before it opens the sealed
12+
> payload. What is *not* done is a hardware run in both directions: see
13+
> [What this still does not give you](#what-this-still-does-not-give-you), which
14+
> has not moved.
15+
916
## What is one-directional, and what is not
1017

1118
The primitives are symmetric. Either peer can bind a channel key into a report
@@ -40,8 +47,8 @@ caller callee
4047
│ appraise callee, seal payload
4148
│ attest own channel key under C
4249
│ POST /task + ChannelOffer(C) ──────▶
43-
│ appraise caller BEFORE acting
44-
│ ◀────────────── response sealed to caller key
50+
│ appraise caller BEFORE opening payload
51+
│ ◀───── provenance record (readable, so it can be chained)
4552
```
4653

4754
Three properties fall out, and each is a requirement rather than a consequence:
@@ -71,6 +78,26 @@ that was wrong in this protocol.
7178
exactly-once. That weaker property is stated here rather than left implied.
7279
2. **Record the outcome, requirement configurable.** A callee does not demand
7380
attestation by default and can be configured to.
81+
- The knob is a three-rung ladder, `require_caller_attestation`:
82+
`"none"` (the default, demands nothing), `"any"` (an offer that appraises,
83+
software assurance is enough), `"hardware"` (the assurance must be
84+
hardware-backed). `"hardware"` without a `caller_verifier` is refused at
85+
construction rather than on every call, because it could never succeed.
86+
- The outcome is one of four values, not three: `not_offered`, `failed`,
87+
`software-only`, `hardware`. A peer that offered nothing and a peer whose
88+
offer did not appraise are different facts and neither may be readable as
89+
the software-only case.
90+
- **An offer that is present and does not appraise is refused at every rung,
91+
including `"none"`.** Demanding nothing means accepting a caller that proves
92+
nothing; it does not mean accepting a broken proof. Without this a
93+
misconfigured attestation path is indistinguishable from a caller that never
94+
had one, which is how a control ends up switched off without anyone deciding
95+
to switch it off.
96+
- The outcome is **in the hashed record body**, always, including
97+
`not_offered`. Omitting it when nothing was appraised would leave an auditor
98+
unable to tell a peer that checked and found nothing from a peer that never
99+
checked. The cost is real and was accepted: it changes the hash of every
100+
record ever emitted, and the example DAGs were regenerated for it.
74101
3. ~~The response is sealed to the caller's attested key.~~ **Withdrawn on
75102
2026-08-09, before implementation.** The argument for it was that an appraised
76103
key which is never used is ceremony. That was wrong on both halves.
@@ -120,6 +147,49 @@ make the requirement configurable, and never let absence look like success. A
120147
callee should be able to say "hardware or nothing" and should not say it by
121148
default.
122149

150+
## What was built
151+
152+
```python
153+
from ca2a_runtime.node import PeerNode
154+
from ca2a_runtime.policy import LocalPolicy
155+
from ca2a_runtime.transport import client
156+
from ca2a_runtime.tee.software import SoftwareProvider
157+
158+
# Callee: demands nothing, records everything (the default).
159+
node = PeerNode(LocalPolicy.of(["read"]))
160+
161+
# Callee: opts in to strictness.
162+
node = PeerNode(LocalPolicy.of(["read"]), require_caller_attestation="any")
163+
164+
# Caller: opts in to being appraised.
165+
client.send_task(base_url, chain, "read", "r0",
166+
payload=b"...", caller_provider=SoftwareProvider())
167+
```
168+
169+
| Piece | Where |
170+
|---|---|
171+
| Stateless challenge (`v1.<expiry>.<random>.<mac>`) | `ca2a_runtime.challenge` |
172+
| Callee-side appraisal, challenge then offer | `attestation.appraise_caller` |
173+
| Requirement ladder and the refusal records | `peer.appraise_caller_runtime` |
174+
| Ordering: appraise before `open_sealed` | `peer.handle_peer_request` |
175+
| `caller_offer` on the wire | `transport.constants`, `transport.a2a_adapter` |
176+
| Challenge on the handshake response | `transport.server`, `transport.wire` |
177+
| Caller-side opt-in | `transport.client.send_task(caller_provider=...)` |
178+
179+
Two consequences worth stating plainly, because neither is free:
180+
181+
**Every record hash changed.** `caller_attestation` is in the hashed body of every
182+
`DelegationRecord`, so records emitted before this change do not hash to what they
183+
used to. The example DAGs under `examples/` were regenerated, and a record loaded
184+
without the field is read as `not_offered` -- the only thing its emitter could
185+
honestly have claimed.
186+
187+
**A challenge does not cross instances.** The secret is per-process, so a callee
188+
behind a load balancer must pin the handshake and the task to one instance or
189+
share a secret between them. This is the stateless scheme's cost, chosen with
190+
open eyes over a challenge store; `test_a_challenge_from_another_instance_does_not_verify`
191+
holds the line.
192+
123193
## What this still does not give you
124194

125195
**It is not simultaneous.** The caller appraises the callee, then the callee

0 commit comments

Comments
 (0)