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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

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

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

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

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

### Changed

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

### Fixed

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

## What is stubbed or not yet implemented

- **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.
- **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.
- **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* 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.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via
- **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:** 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`.
- **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`.

## v1.0: Stable profile

Expand Down
76 changes: 73 additions & 3 deletions docs/spec/mutual-attestation.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
# Mutual Attestation

---
Status: Proposal
Status: Implemented in the reference transport
Written: 2026-08-09
Stability: Unstable
---

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

## What is one-directional, and what is not

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

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

## What was built

```python
from ca2a_runtime.node import PeerNode
from ca2a_runtime.policy import LocalPolicy
from ca2a_runtime.transport import client
from ca2a_runtime.tee.software import SoftwareProvider

# Callee: demands nothing, records everything (the default).
node = PeerNode(LocalPolicy.of(["read"]))

# Callee: opts in to strictness.
node = PeerNode(LocalPolicy.of(["read"]), require_caller_attestation="any")

# Caller: opts in to being appraised.
client.send_task(base_url, chain, "read", "r0",
payload=b"...", caller_provider=SoftwareProvider())
```

| Piece | Where |
|---|---|
| Stateless challenge (`v1.<expiry>.<random>.<mac>`) | `ca2a_runtime.challenge` |
| Callee-side appraisal, challenge then offer | `attestation.appraise_caller` |
| Requirement ladder and the refusal records | `peer.appraise_caller_runtime` |
| Ordering: appraise before `open_sealed` | `peer.handle_peer_request` |
| `caller_offer` on the wire | `transport.constants`, `transport.a2a_adapter` |
| Challenge on the handshake response | `transport.server`, `transport.wire` |
| Caller-side opt-in | `transport.client.send_task(caller_provider=...)` |

Two consequences worth stating plainly, because neither is free:

**Every record hash changed.** `caller_attestation` is in the hashed body of every
`DelegationRecord`, so records emitted before this change do not hash to what they
used to. The example DAGs under `examples/` were regenerated, and a record loaded
without the field is read as `not_offered` -- the only thing its emitter could
honestly have claimed.

**A challenge does not cross instances.** The secret is per-process, so a callee
behind a load balancer must pin the handshake and the task to one instance or
share a secret between them. This is the stateless scheme's cost, chosen with
open eyes over a challenge store; `test_a_challenge_from_another_instance_does_not_verify`
holds the line.

## What this still does not give you

**It is not simultaneous.** The caller appraises the callee, then the callee
Expand Down
Loading