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

## [Unreleased]

## [0.4.0] - 2026-09-30

### Security

- A delegate with no enclave could be recorded as `caller_attestation="hardware"`
by relaying an honest hardware peer's attestation: it asked that peer's public
`/.well-known/ca2a/channel` endpoint to attest under the target's challenge and
presented the result as its `caller_offer`. Caller offers are now minted in a
separate role, signed under their own binding prefixes (`ca2a-*-caller-v2|`),
and the handshake endpoint only mints callee-role offers. The caller must also
send `caller_possession`, a MAC over the request transcript keyed from X25519
between its attested channel key and the callee's, which the callee verifies
before recording any outcome.

### Changed

- **Breaking for callers that send `caller_offer`** (`ca2a-caller-offer-v2`). A
pre-v2 caller offer has no `role` and no `caller_possession` and is refused with
`ATTESTATION_FAILED`. Callers that send no offer, and the handshake response,
are unchanged on the wire. `BaseProvider.attest` and the binding helpers
(`tpm_qualifying_data`, `snp_report_data`, `tdx_report_data`) take a keyword
`role`, defaulting to the callee role; a custom `Verifier` must derive the
binding under `report.role`.

## [0.3.1] - 2026-09-26

### Fixed
Expand Down Expand Up @@ -410,7 +434,8 @@ built/stubbed boundary.
- The sealed channel does not by itself establish the enclave-held-private-key property; that is a hardware attestation guarantee that lands with real-hardware validation.
- Alpha schemas: the delegation credential and TRACE link schemas are not yet stable or versioned, and peer attestation evidence is not yet RATS/EAT conformant.

[Unreleased]: https://github.com/agentrust-io/ca2a/compare/v0.3.1...main
[Unreleased]: https://github.com/agentrust-io/ca2a/compare/v0.4.0...main
[0.4.0]: https://github.com/agentrust-io/ca2a/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/agentrust-io/ca2a/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/agentrust-io/ca2a/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/agentrust-io/ca2a/compare/v0.1.0a1...v0.2.0
Expand Down
9 changes: 8 additions & 1 deletion docs/spec/attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ binding = sha256(prefix || len32(public_key) || public_key || len32(nonce) || no
| 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 |

Those are the **callee-role** prefixes: what the handshake endpoint signs. A caller
offer (see [mutual attestation](mutual-attestation.md)) is signed under its own
prefix per platform, `ca2a-tpm-caller-v2|`, `ca2a-snp-caller-v2|` and
`ca2a-tdx-caller-v2|`, selected by the report's `role`. A verifier MUST derive the
binding under the report's role, so an offer relabelled from one role to the other
recomputes different bytes and is rejected.

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.
- **Domain-separated per platform and per role.** The prefixes differ so a report collected on one platform cannot be replayed as another's evidence, and so a callee offer, which anybody can obtain from the public handshake endpoint under a nonce of their choosing, cannot be presented as a caller offer.

`ca2a_runtime.tee.binding` holds the single derivation the three providers share.

Expand Down
41 changes: 40 additions & 1 deletion docs/spec/mutual-attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,43 @@ property. An earlier draft of this document claimed the key would be ceremonial
unless the response were sealed to it; see the withdrawn decision below for why
that was wrong in this protocol.

**The caller must prove it holds that key (`ca2a-caller-offer-v2`).** An
appraised offer shows that *some* enclave holds the offered key, not that the
party presenting it is that enclave. Before v2 nothing joined the two: the handshake
endpoint attests its node's key under any nonce anybody sends, and callee and
caller offers were signed over the same binding, so a delegate with no enclave
could take challenge C from target T, fetch honest hardware peer V's
`/channel?nonce=C` offer, present it as its own, and be recorded as
`caller_attestation="hardware"`. Two changes close that, each sufficient against
that relay on its own:

- *Role separation.* Every offer is minted for a role, carried as
`attestation.role` and folded into the signed binding prefix
([attestation.md](attestation.md#the-signed-key-and-nonce-binding)). The handshake
endpoint only ever mints `callee` offers; a callee accepts only `caller` offers
as `caller_offer`, and a caller accepts only `callee` offers from a handshake. A
node mints a caller offer only for its own outbound call.
- *Proof of possession.* With every `caller_offer` the caller sends
`caller_possession = {"version": "ca2a-caller-offer-v2", "mac": <hex>}`. The
MAC is HMAC-SHA256 over the JCS-canonical transcript (version, challenge, callee
channel key, caller channel key, `credential_id`, `subject`,
`requested_capability`, `record_id`, `payload_sha256`, `parent_record_hash`, the
holder proof's signature), keyed by HKDF-SHA256 (info
`ca2a/caller-possession/v2/key`) over caller key, callee key and
X25519(caller channel private key, callee channel public key). The callee
recomputes it with its own channel private key and the key the caller's report
attests, and compares in constant time. A relayed offer, even a genuine
caller-role one, fails because the presenter lacks that private key; a captured
offer and proof fail on any other request or callee because the transcript and
the key differ.

A missing, malformed, wrong-version or non-verifying proof is refused whenever a
`caller_offer` is present, at every rung including `"none"`, with the denial
record stating `failed`. This is a breaking change for callers that send
`caller_offer`: a pre-v2 offer has no `role` and no `caller_possession`, parses as
a callee offer, and is refused with an error naming `ca2a-caller-offer-v2`. Callers
that send no offer, and handshake responses, are unchanged on the wire.

## Decisions taken 2026-08-09

1. **Stateless HMAC challenge** (option B below). Works across instances with no
Expand Down Expand Up @@ -181,9 +218,11 @@ The same rung is reachable from a config file. `ca2a start` reads
|---|---|
| Stateless challenge (`v1.<expiry>.<random>.<mac>`) | `ca2a_runtime.challenge` |
| Callee-side appraisal, challenge then offer | `attestation.appraise_caller` |
| Role-separated binding prefixes | `tee.binding.role_prefix` |
| Caller proof of possession | `attestation.prove_caller_possession`, `attestation.verify_caller_possession` |
| 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` |
| `caller_offer` and `caller_possession` 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=...)` |

Expand Down
9 changes: 7 additions & 2 deletions docs/spec/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,18 @@ Namespaced keys on A2A `metadata` (message and/or params):
| `https://agentrust-io.com/extensions/ca2a/v0.1/record_id` | string | Provenance record id for this hop |
| `https://agentrust-io.com/extensions/ca2a/v0.1/parent_record_hash` | string or `null` | Parent TRACE/provenance hash; `null` for a root hop |
| `https://agentrust-io.com/extensions/ca2a/v0.1/sealed_payload` | string (base64url) or omitted | Opaque sealed ciphertext only, not a verified measurement binding |
| `https://agentrust-io.com/extensions/ca2a/v0.1/caller_offer` | channel-offer object or omitted | The caller's own attested channel key, bound to a challenge the callee issued ([mutual attestation](mutual-attestation.md)) |
| `https://agentrust-io.com/extensions/ca2a/v0.1/caller_offer` | channel-offer object or omitted | The caller's own attested channel key, bound to a challenge the callee issued, with `attestation.role` set to `"caller"` ([mutual attestation](mutual-attestation.md)) |
| `https://agentrust-io.com/extensions/ca2a/v0.1/caller_possession` | `{"version": "ca2a-caller-offer-v2", "mac": <hex>}` or omitted | The caller's proof that it holds the private half of `caller_offer`'s channel key. Required whenever `caller_offer` is present |

`caller_offer` is **optional**, unlike `parent_record_hash`: a caller that cannot
attest omits the key entirely rather than sending `null`, and a callee serves it
by default. A key that is present but is not a well-formed offer is malformed and
fails closed, so a caller cannot get itself treated as unattested by sending
rubbish. Its object shape is the same one the handshake endpoint returns.
rubbish. Its object shape is the same one the handshake endpoint returns, plus
`attestation.role: "caller"`. The handshake endpoint omits `role`, which reads as
`"callee"`, and a callee refuses a callee-role offer presented as `caller_offer`.
An offer without `caller_possession`, or whose proof does not verify, is refused
([mutual attestation](mutual-attestation.md)).

Constants and helpers live in `ca2a_runtime.transport`.

Expand Down
4 changes: 2 additions & 2 deletions index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ cA2A is a profile on A2A that adds signed attenuated delegation, peer appraisal,
[What this proves, and what it does not](LIMITATIONS.md){ .md-button }

!!! tip "TL;DR"
[ca2a-runtime](https://pypi.org/project/ca2a-runtime/) 0.3.1 (MIT developer preview; the PyPI name `ca2a` belongs to an unrelated project) rejects scope escalation offline with no hardware or running peer. SEV-SNP and TDX appraisal ran on real Azure and GCP evidence, including an Azure SEV-SNP peer calling a GCP TDX peer on 2026-07-27, but peer appraisal is one-directional so far and binding the seal to a verified measurement on a live call is on the roadmap.
[ca2a-runtime](https://pypi.org/project/ca2a-runtime/) 0.4.0 (MIT developer preview; the PyPI name `ca2a` belongs to an unrelated project) rejects scope escalation offline with no hardware or running peer. SEV-SNP and TDX appraisal ran on real Azure and GCP evidence, including an Azure SEV-SNP peer calling a GCP TDX peer on 2026-07-27, but peer appraisal is one-directional so far and binding the seal to a verified measurement on a live call is on the roadmap.

<div class="grid cards" markdown>

Expand Down Expand Up @@ -71,4 +71,4 @@ Software and hardware modes provide different assurance. Peer appraisal has been

For the architecture and trust boundaries, read [How It Works](docs/concepts.md). The normative profile, with the delegation chain, sealed channel, and conformance rules, is in [Profile](docs/spec/profile.md).

**Status:** ca2a-runtime 0.3.1 developer preview · MIT · hosting at the Agentic AI Foundation proposed, not accepted · Sponsored by OPAQUE, which funds the engineering, infrastructure and confidential-computing work behind these projects.
**Status:** ca2a-runtime 0.4.0 developer preview · MIT · hosting at the Agentic AI Foundation proposed, not accepted · Sponsored by OPAQUE, which funds the engineering, infrastructure and confidential-computing work behind these projects.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "ca2a-runtime"
version = "0.3.1"
version = "0.4.0"
description = "Confidential agent-to-agent runtime: attested, attenuated delegation and sealed peer channels for A2A"
readme = "README.md"
license = { text = "MIT" }
Expand Down
6 changes: 3 additions & 3 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2161,9 +2161,9 @@ typing-inspection==0.4.4 \
--hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
--hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
# via pydantic
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
urllib3==2.8.0 \
--hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \
--hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63
# via requests
uvicorn==0.53.0 \
--hash=sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e \
Expand Down
6 changes: 3 additions & 3 deletions requirements/release.txt
Original file line number Diff line number Diff line change
Expand Up @@ -475,9 +475,9 @@ twine==7.0.0 \
--hash=sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177 \
--hash=sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7
# via -r requirements/release.in
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
urllib3==2.8.0 \
--hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \
--hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63
# via
# id
# requests
Expand Down
Loading
Loading