From effb97eaec5fb582716bc6c864e065397e43dcc2 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sun, 9 Aug 2026 21:31:31 -0700 Subject: [PATCH] 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) --- CHANGELOG.md | 14 + LIMITATIONS.md | 2 +- ROADMAP.md | 2 +- docs/spec/mutual-attestation.md | 76 ++- docs/spec/transport.md | 24 + examples/cross-operator-delegation/chain.json | 12 +- examples/cross-operator-delegation/dag.json | 10 +- examples/rejection-with-proof/chain.json | 18 +- examples/rejection-with-proof/dag.json | 20 +- src/ca2a_runtime/attestation.py | 33 ++ src/ca2a_runtime/cli.py | 16 +- src/ca2a_runtime/errors.py | 16 + src/ca2a_runtime/node.py | 52 +- src/ca2a_runtime/peer.py | 233 ++++++++- src/ca2a_runtime/provenance.py | 48 +- src/ca2a_runtime/transport/a2a_adapter.py | 16 + src/ca2a_runtime/transport/client.py | 80 ++- src/ca2a_runtime/transport/constants.py | 7 + src/ca2a_runtime/transport/server.py | 13 +- src/ca2a_runtime/transport/wire.py | 51 +- tests/unit/test_committed_examples_verify.py | 75 +++ tests/unit/test_live_call.py | 67 +++ tests/unit/test_mutual_attestation.py | 491 ++++++++++++++++++ 23 files changed, 1307 insertions(+), 69 deletions(-) create mode 100644 tests/unit/test_committed_examples_verify.py create mode 100644 tests/unit/test_mutual_attestation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f2bed48..1977397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/LIMITATIONS.md b/LIMITATIONS.md index bafa3dc..5758a70 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index eeb019b..d3dc3f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/spec/mutual-attestation.md b/docs/spec/mutual-attestation.md index 923b5be..23aa8a7 100644 --- a/docs/spec/mutual-attestation.md +++ b/docs/spec/mutual-attestation.md @@ -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 @@ -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: @@ -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. @@ -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...`) | `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 diff --git a/docs/spec/transport.md b/docs/spec/transport.md index cce2ea9..b64a02a 100644 --- a/docs/spec/transport.md +++ b/docs/spec/transport.md @@ -11,6 +11,7 @@ cA2A is a profile on A2A, not a competing transport. A2A moves tasks and context | Hand-off into `handle_peer_request` once a `PeerRequest` exists | Implemented in-process; callers invoke it after parsing | | Reference HTTP server/client (`ca2a_runtime.transport.server`/`client`) | Implemented, software mode. A reference transport, not part of the profile | | Live attestation handshake on an inbound call | Implemented in software mode (`ca2a_runtime.attestation`, `assurance="none"`) | +| Callee appraises the caller (mutual attestation) | Implemented in software mode: challenge on the handshake, `caller_offer` on the request, appraised before the payload is opened. Off by default; see [mutual-attestation.md](mutual-attestation.md) | | Seal gated on the appraised channel key on a live call | Implemented in software mode | | Seal bound to a hardware-verified measurement | Not yet: needs a real quote via the `verifier` seam (Tier 3) | | `ca2a start` CLI listener | Implemented: builds a `PeerNode` from a config file and serves it over the reference transport | @@ -44,9 +45,32 @@ 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)) | + +`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. Constants and helpers live in `ca2a_runtime.transport`. +### Handshake response + +`GET /.well-known/ca2a/channel?nonce=` returns the callee's attested channel +key and, additively, the challenge for the other direction: + +```json +{ + "channel_public_key": "...", + "attestation": {"platform": "...", "measurement": "...", "public_key": "...", "nonce": ""}, + "challenge": "v1..." +} +``` + +`challenge` is omitted by a callee that issues none, so a caller written against +the older response sees exactly what it saw before and simply does not attest. + ## Attachment points The credential and sealing metadata are carried in A2A `metadata` maps on the task message (and optionally params-level metadata), alongside the payload A2A already moves. cA2A does not rewrite the A2A message, change its routing, or interpose a new transport under it. The Signed Agent Card remains the A2A identity anchor; cA2A treats it as the anchor the delegation credential's `subject` and the peer's attestation measurement are checked against (attestation check on the live path is not yet wired). diff --git a/examples/cross-operator-delegation/chain.json b/examples/cross-operator-delegation/chain.json index aefc85b..91447f5 100644 --- a/examples/cross-operator-delegation/chain.json +++ b/examples/cross-operator-delegation/chain.json @@ -2,8 +2,8 @@ "chain": [ { "credential_id": "cred-0", - "issuer": "86ca3598c75129a1e14d6e6bbf0ae0ccc58ddee2aba7fe1df345d48172b4aad1", - "subject": "97e57072a0ee5ad5ca7886f5f8c8be313fc13e9fd1dfd38d18d019457bf15281", + "issuer": "522df0a349190a3cfabc4774b215a765180bd879abcdf9c67355b98e39266a44", + "subject": "826df306d5a746066527b46a2a232eaf37bfe6919ccef788db03143af6f54e26", "scope": [ "task:admin", "task:read", @@ -11,19 +11,19 @@ ], "depth": 0, "parent_id": null, - "signature": "749c58ccc521821e08ade6d0c423a287fa0803daf1b2f26ed56ac037f41ab8e8f0dbee17d9f37d99129a0d88f5161df9dcf5ddba49012fc1ff5b9b88733bd002" + "signature": "5f0ba300b3437efbdcfb4aaa7b8126d4ad085d2f5db859aa361f95750e3909f42f5a983c3d08fccca096330c0245843d69c38c792bcb6d81fda0a7919e4cee07" }, { "credential_id": "cred-1", - "issuer": "97e57072a0ee5ad5ca7886f5f8c8be313fc13e9fd1dfd38d18d019457bf15281", - "subject": "971c8fc1db051ad5ddf2884ce01cb1a8833b1c3eb1d1e14bb589ecbe8a9716fe", + "issuer": "826df306d5a746066527b46a2a232eaf37bfe6919ccef788db03143af6f54e26", + "subject": "d7b5aa81bca7bf963eb50c3f357bed5df10d94f0537310c45840ff41d094778e", "scope": [ "task:read", "task:write" ], "depth": 1, "parent_id": "cred-0", - "signature": "56cea40981b23acef6035dbb54f4f3ec51dede9737743cb55aaa3a11e78f446724bd4a83f6c28232f96969ba6153ae8e716e740abbee0053da36805e8d483506" + "signature": "bd2de5b1b88cc783596bfefd062fa3e8e871b3af7bdbdd55073e4dce8d1bbec7443b341c3f97dc74a09dde3425256751abaf1cf8d6a77817b2777d256e45110f" } ] } \ No newline at end of file diff --git a/examples/cross-operator-delegation/dag.json b/examples/cross-operator-delegation/dag.json index 3866ea7..d4270bc 100644 --- a/examples/cross-operator-delegation/dag.json +++ b/examples/cross-operator-delegation/dag.json @@ -3,23 +3,25 @@ { "record_id": "rec-0", "credential_id": "cred-0", - "subject": "97e57072a0ee5ad5ca7886f5f8c8be313fc13e9fd1dfd38d18d019457bf15281", + "subject": "826df306d5a746066527b46a2a232eaf37bfe6919ccef788db03143af6f54e26", "scope": [ "task:admin", "task:read", "task:write" ], - "parent_record_hash": null + "parent_record_hash": null, + "caller_attestation": "not_offered" }, { "record_id": "rec-1", "credential_id": "cred-1", - "subject": "971c8fc1db051ad5ddf2884ce01cb1a8833b1c3eb1d1e14bb589ecbe8a9716fe", + "subject": "d7b5aa81bca7bf963eb50c3f357bed5df10d94f0537310c45840ff41d094778e", "scope": [ "task:read", "task:write" ], - "parent_record_hash": "8eb94950658787327af0ebb91930f6f1eaa0464e53eac35cb6d116fbaf6d95cc" + "parent_record_hash": "f42aca71a8e07f9663f62ea5bd8f5f30ba0bdaba4f37d72ef66dbe2be9f62242", + "caller_attestation": "not_offered" } ] } \ No newline at end of file diff --git a/examples/rejection-with-proof/chain.json b/examples/rejection-with-proof/chain.json index ed17224..25e9cfa 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": "f4e97d6f0f3c0abefa7daa37d9965f1bd96297c7ed1210e2520c3a8841d6c2ff", - "subject": "2e79eaa089777f566934b15ebd93af8f007f6bdad0a70047f7c21079b5771f31", + "issuer": "03361e4dd891ebed342fdf0d3855bb561fe001362c0fa7b74d9f13b9cd2f38b6", + "subject": "15b96b4b80c8a1170faf3aef96f8686c43f0fc3113327e4e6c738870b9621ae4", "scope": [ "task:read", "task:write", @@ -12,12 +12,12 @@ ], "depth": 0, "parent_id": null, - "signature": "7b36ce1f8b1488b9ad31e4854bfde766d4dab9bb8b1a1ee256d067137527458dbcf26568a7d34db56122a2e7aa862395c61c65bb627daad1b9f901ff999a3800" + "signature": "6eb164d43a0951a06c49a66fa2faab98387fce2acfe15147bba8f1dea4d26cc09270eb74b6e0696df333d3040cac697bb80f90f2b44aaba66b0e9fde51711705" }, { "credential_id": "cred-1-researcher", - "issuer": "2e79eaa089777f566934b15ebd93af8f007f6bdad0a70047f7c21079b5771f31", - "subject": "cc141e5c3eca4d9fb361e78737c326ae71816a20408fb604414d5af0d7991203", + "issuer": "15b96b4b80c8a1170faf3aef96f8686c43f0fc3113327e4e6c738870b9621ae4", + "subject": "68fef2e89c77143d686585c11207bc46218fa0934deaac76ea2520b5136cb3f6", "scope": [ "task:read", "tool:purchase", @@ -25,18 +25,18 @@ ], "depth": 1, "parent_id": "cred-0-orchestrator", - "signature": "06a4c58f36fc7e1fc5b0190f728301d33b17ef962820c1b501a8bd8f52f0c7182eb10f982f4f3ece858429c158e8d2f3531034577699f170f63de4d47353c70e" + "signature": "d7a9ecd41274d0be1941ffbbb536c1b6bcb731ec479d784b6d01c7414244e77bc46561bb676f002ba61c51009aa22b45d8d8977898c6490eb9ea591ccbdd6704" }, { "credential_id": "cred-2-retriever", - "issuer": "cc141e5c3eca4d9fb361e78737c326ae71816a20408fb604414d5af0d7991203", - "subject": "21911b98fd592dd3da353f25080bb93bc668d09e06fc8a5f133875064d73a96b", + "issuer": "68fef2e89c77143d686585c11207bc46218fa0934deaac76ea2520b5136cb3f6", + "subject": "9b127317920239acf0e21df8fc12171bfc2f46e7ddd2fff92fd6928e76103da6", "scope": [ "tool:search" ], "depth": 2, "parent_id": "cred-1-researcher", - "signature": "a5f9a7c4311eb1915f1479bd842078f0d61a30772043d406460ab096a2d3eda020a9e55ff9ccf63e702853a796dd156a4311d54861742a3928d15fed0c834c00" + "signature": "8319d709c5ac022fa79bd8e91d81b09618add8917d5196abeb6478a94fac912edda89ba8057c2036b8a780761c558d26d0ab1290f014af96bcc65996055ffd06" } ] } diff --git a/examples/rejection-with-proof/dag.json b/examples/rejection-with-proof/dag.json index fd5f93c..ee64f78 100644 --- a/examples/rejection-with-proof/dag.json +++ b/examples/rejection-with-proof/dag.json @@ -3,43 +3,47 @@ { "record_id": "rec-0-orchestrator", "credential_id": "cred-0-orchestrator", - "subject": "2e79eaa089777f566934b15ebd93af8f007f6bdad0a70047f7c21079b5771f31", + "subject": "15b96b4b80c8a1170faf3aef96f8686c43f0fc3113327e4e6c738870b9621ae4", "scope": [ "task:read", "task:write", "tool:purchase", "tool:search" ], - "parent_record_hash": null + "parent_record_hash": null, + "caller_attestation": "not_offered" }, { "record_id": "rec-1-researcher", "credential_id": "cred-1-researcher", - "subject": "cc141e5c3eca4d9fb361e78737c326ae71816a20408fb604414d5af0d7991203", + "subject": "68fef2e89c77143d686585c11207bc46218fa0934deaac76ea2520b5136cb3f6", "scope": [ "task:read", "tool:purchase", "tool:search" ], - "parent_record_hash": "d7780be8f7359f0a544dc9ce46369f4a02ae91c65c5544149ed28bb42d0bf305" + "parent_record_hash": "fdb29e6fa794702a4777cb1be2c17210e5e9b79058115bac5d4303ce4acb5722", + "caller_attestation": "not_offered" }, { "record_id": "rec-2-retriever", "credential_id": "cred-2-retriever", - "subject": "21911b98fd592dd3da353f25080bb93bc668d09e06fc8a5f133875064d73a96b", + "subject": "9b127317920239acf0e21df8fc12171bfc2f46e7ddd2fff92fd6928e76103da6", "scope": [ "tool:search" ], - "parent_record_hash": "3c80a30e428bc0bb55fe53bea23a4f9676ebf4b7ea84d9018670791ddc49acff" + "parent_record_hash": "35b36816653f1a93a475cf218bcbec84c550e626bbd56169150daffb1ed33c68", + "caller_attestation": "not_offered" }, { "record_id": "rec-denied-purchase", "credential_id": "cred-2-retriever", - "subject": "21911b98fd592dd3da353f25080bb93bc668d09e06fc8a5f133875064d73a96b", + "subject": "9b127317920239acf0e21df8fc12171bfc2f46e7ddd2fff92fd6928e76103da6", "scope": [ "tool:search" ], - "parent_record_hash": "9064b60d017e4de781a5cdfdc196c5593e77ae4488dd4b91dbfc086d76ab1375", + "parent_record_hash": "2f96e5c7e151017fd6a7e278fa97f124d71f5d24c39b23a3b21b9e7f42588ebf", + "caller_attestation": "not_offered", "decision": "deny", "requested_capability": "tool:purchase", "effective_scope": [ diff --git a/src/ca2a_runtime/attestation.py b/src/ca2a_runtime/attestation.py index bc0c00c..d6e2f81 100644 --- a/src/ca2a_runtime/attestation.py +++ b/src/ca2a_runtime/attestation.py @@ -11,6 +11,10 @@ - the caller calls :func:`verify_offer` to check the report binds the offered key to that nonce, then :func:`seal_to_peer` to seal a payload to it. +The same primitives run the other direction: a callee that issued a challenge +appraises the caller's own attested channel key with :func:`appraise_caller`. See +``docs/spec/mutual-attestation.md`` for what that adds and what it still does not. + Two assurance modes, never blended. In ``software-only`` mode there is no hardware guarantee: :func:`verify_offer` returns ``assurance="none"`` and the seal protects against a passive network observer only. On a confidential VM a @@ -27,6 +31,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from ca2a_runtime.challenge import verify_challenge from ca2a_runtime.channel import SealedChannel, generate_channel_keypair from ca2a_runtime.errors import AttestationFailed from ca2a_runtime.tee.base import AttestationReport, BaseProvider @@ -116,6 +121,34 @@ def verify_offer( ) +def appraise_caller( + offer: ChannelOffer, + *, + challenge_secret: bytes, + verifier: Verifier | None = None, +) -> VerifiedPeer: + """Appraise a *caller's* offer against a challenge this peer issued. + + The mirror image of :func:`verify_offer`, and the two differ in where the + expected nonce comes from. A caller appraising a callee remembers the nonce it + sent, so :func:`verify_offer` compares the report against a value the caller + already holds. A callee keeps no such state: the challenge scheme is + stateless by design (see :mod:`ca2a_runtime.challenge`), so the nonce arrives + inside the report and the callee re-derives whether it is one of its own. + + That makes the order here load-bearing. ``verify_challenge`` runs first and is + the substantive check: it recomputes the MAC under this peer's secret and + rejects an expired window, which is what establishes that this peer issued + the nonce and issued it recently. Only then is the nonce trustworthy enough to + appraise the report against, so passing ``expected_nonce=offer.report.nonce`` + into :func:`verify_offer` is not the circular check it looks like: by that + point the nonce is authenticated, and what remains for ``verify_offer`` to do + is bind the offered channel key to it and establish the assurance level. + """ + verify_challenge(challenge_secret, offer.report.nonce) + return verify_offer(offer, expected_nonce=offer.report.nonce, verifier=verifier) + + def seal_to_peer(peer: VerifiedPeer, payload: bytes, *, aad: bytes = b"") -> bytes: """Seal ``payload`` to a verified peer's channel key.""" return SealedChannel(peer.public_key).seal(payload, aad=aad) diff --git a/src/ca2a_runtime/cli.py b/src/ca2a_runtime/cli.py index 3c03c8c..d9d5ad8 100644 --- a/src/ca2a_runtime/cli.py +++ b/src/ca2a_runtime/cli.py @@ -12,7 +12,12 @@ from ca2a_runtime.config import Ca2aConfig from ca2a_runtime.delegation import DelegationCredential, verify_chain from ca2a_runtime.errors import CA2AError, ConfigError, InvalidCredential, ProvenanceLinkBroken -from ca2a_runtime.provenance import DelegationRecord, cross_check_chain, verify_dag +from ca2a_runtime.provenance import ( + CALLER_NOT_OFFERED, + DelegationRecord, + cross_check_chain, + verify_dag, +) from ca2a_verify import verify_chain_file @@ -93,6 +98,12 @@ def _load_records(path: str) -> list[DelegationRecord]: if item.get("denial_reason") is None else str(item["denial_reason"]) ), + # A record written before this field existed hashes as + # "not_offered", which is what its emitter could honestly have + # claimed: it never appraised a caller. + caller_attestation=str( + item.get("caller_attestation", CALLER_NOT_OFFERED) + ), ) ) except (KeyError, TypeError, ValueError) as exc: @@ -117,6 +128,9 @@ def _cmd_verify_dag(args: argparse.Namespace) -> int: "verified": True, "records": len(records), "leaf_scope": sorted(leaf.scope), + # Printed always, including "not_offered". A verifier that only mentioned + # attestation when there was some would let a reader skim past its absence. + "leaf_caller_attestation": leaf.caller_attestation, } if leaf.denied: # The DAG verifies AND it documents a refusal. Both are true, and a diff --git a/src/ca2a_runtime/errors.py b/src/ca2a_runtime/errors.py index b13cd86..e941fec 100644 --- a/src/ca2a_runtime/errors.py +++ b/src/ca2a_runtime/errors.py @@ -58,9 +58,25 @@ class AttestationUnsupported(CA2AError): class AttestationFailed(CA2AError): + """An attestation report, challenge, or channel offer did not appraise. + + Carries the provenance record for the refusal on ``record`` when it was + raised somewhere with enough context to build one (the callee refusing an + unattested or badly attested caller). Elsewhere ``record`` is None: the + challenge and offer primitives have no delegation chain to attribute a + refusal to. As with :class:`ScopeNotPermitted`, the call still fails closed + and the record is evidence of the refusal, not a way to continue. + """ + code = "ATTESTATION_FAILED" http_status = 412 + def __init__( + self, message: str, *, detail: str | None = None, record: object | None = None + ) -> None: + super().__init__(message, detail=detail) + self.record = record + class SealedChannelError(CA2AError): code = "SEALED_CHANNEL_ERROR" diff --git a/src/ca2a_runtime/node.py b/src/ca2a_runtime/node.py index b683097..1255ff5 100644 --- a/src/ca2a_runtime/node.py +++ b/src/ca2a_runtime/node.py @@ -14,17 +14,33 @@ from typing import Any -from ca2a_runtime.attestation import ChannelOffer, attest_channel +from ca2a_runtime.attestation import ChannelOffer, Verifier, attest_channel +from ca2a_runtime.challenge import DEFAULT_TTL_SECONDS, generate_secret, issue_challenge from ca2a_runtime.channel import generate_channel_keypair -from ca2a_runtime.errors import TransportError -from ca2a_runtime.peer import PeerResult, handle_peer_request +from ca2a_runtime.errors import ConfigError, TransportError +from ca2a_runtime.peer import ( + REQUIRE_HARDWARE, + REQUIRE_NONE, + REQUIREMENT_VALUES, + PeerResult, + handle_peer_request, +) from ca2a_runtime.policy import Policy from ca2a_runtime.tee.base import BaseProvider from ca2a_runtime.tee.software import SoftwareProvider class PeerNode: - """A callee holding a stable enclave channel key, a policy, and a provider.""" + """A callee holding a stable enclave channel key, a policy, and a provider. + + Also holds the secret behind the challenges it issues for mutual attestation. + The secret is per-process and never persisted: a restarted node is a different + enclave, and a challenge it never issued should not verify. + + ``require_caller_attestation`` defaults to demanding nothing of the caller (see + :data:`ca2a_runtime.peer.REQUIRE_NONE`). A deployment that wants "hardware or + nothing" can say so; it is not said for it. + """ def __init__( self, @@ -32,16 +48,41 @@ def __init__( *, provider: BaseProvider | None = None, max_depth: int = 8, + require_caller_attestation: str = REQUIRE_NONE, + caller_verifier: Verifier | None = None, + challenge_ttl_seconds: int = DEFAULT_TTL_SECONDS, ) -> None: + if require_caller_attestation not in REQUIREMENT_VALUES: + raise ConfigError( + f"require_caller_attestation must be one of {sorted(REQUIREMENT_VALUES)}, " + f"got {require_caller_attestation!r}" + ) + if require_caller_attestation == REQUIRE_HARDWARE and caller_verifier is None: + # verify_offer already refuses a hardware report with no verifier, so + # this would fail on every call. Failing at construction turns a + # runtime surprise into a misconfiguration the operator sees at once. + raise ConfigError( + "require_caller_attestation='hardware' needs a caller_verifier", + detail="a hardware report cannot be appraised without one, so every " + "call would be refused", + ) self.policy = policy self.provider: BaseProvider = provider if provider is not None else SoftwareProvider() self.max_depth = max_depth + self.require_caller_attestation = require_caller_attestation + self.caller_verifier = caller_verifier + self.challenge_ttl_seconds = challenge_ttl_seconds self._private_key, self.channel_public_key = generate_channel_keypair() + self._challenge_secret = generate_secret() def offer(self, nonce: str) -> ChannelOffer: """Re-attest the stable enclave channel key under a caller-supplied nonce.""" return attest_channel(self.provider, self.channel_public_key, nonce) + def issue_challenge(self) -> str: + """Issue a challenge for the caller to bind its own channel key into.""" + return issue_challenge(self._challenge_secret, ttl_seconds=self.challenge_ttl_seconds) + def handle(self, message: dict[str, Any]) -> PeerResult: """Parse a cA2A-profile A2A message and run the full inbound pipeline.""" from ca2a_runtime.transport.a2a_adapter import parse_peer_request @@ -54,4 +95,7 @@ def handle(self, message: dict[str, Any]) -> PeerResult: policy=self.policy, enclave_private_key=self._private_key, max_depth=self.max_depth, + challenge_secret=self._challenge_secret, + require_caller_attestation=self.require_caller_attestation, + caller_verifier=self.caller_verifier, ) diff --git a/src/ca2a_runtime/peer.py b/src/ca2a_runtime/peer.py index a6c232b..dad2598 100644 --- a/src/ca2a_runtime/peer.py +++ b/src/ca2a_runtime/peer.py @@ -5,15 +5,22 @@ 1. verifies the chain (signature, continuity, attenuation, depth, replay); 2. computes the effective scope as the leaf's delegated scope intersected with the callee's local policy; -3. enforces: the requested capability must be in the effective scope; -4. emits a provenance record for the accepted hop, linked to its parent. +3. appraises what the caller is *running*, if it offered an attestation bound to + a challenge this callee issued (see ``docs/spec/mutual-attestation.md``); +4. enforces: the requested capability must be in the effective scope; +5. emits a provenance record for the accepted hop, linked to its parent. + +Steps 1-2 are authorization and say what the caller is *allowed to ask for*; step +3 is appraisal and says what the caller *is*. They are independent, and both run +before the callee opens the sealed payload -- appraising afterwards would mean an +unattested caller had already had its work done. `enforce_peer_call` is the enforcement decision core. `handle_peer_request` -composes it into the full transport-agnostic inbound pipeline: verify, enforce, -open any sealed payload with the enclave key, and emit a provenance record. A -transport (an A2A server) parses its wire format into a `PeerRequest` and calls -this; cA2A does not define the transport itself, only what the peer does with a -parsed request. +composes it into the full transport-agnostic inbound pipeline: verify, appraise, +enforce, open any sealed payload with the enclave key, and emit a provenance +record. A transport (an A2A server) parses its wire format into a `PeerRequest` +and calls this; cA2A does not define the transport itself, only what the peer +does with a parsed request. """ from __future__ import annotations @@ -22,11 +29,46 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from ca2a_runtime.attestation import ChannelOffer, Verifier, appraise_caller from ca2a_runtime.channel import open_sealed from ca2a_runtime.delegation.credential import DelegationCredential, verify_chain -from ca2a_runtime.errors import ScopeNotPermitted, SealedChannelError +from ca2a_runtime.errors import ( + AttestationFailed, + ConfigError, + ScopeNotPermitted, + SealedChannelError, +) from ca2a_runtime.policy import Policy -from ca2a_runtime.provenance import DelegationRecord, denial_record_for, record_for +from ca2a_runtime.provenance import ( + CALLER_FAILED, + CALLER_HARDWARE, + CALLER_NOT_OFFERED, + CALLER_SOFTWARE_ONLY, + DelegationRecord, + denial_record_for, + record_for, +) + +#: How much the callee demands of the caller's own runtime. +#: +#: ``"none"`` is the default and demands nothing: the outcome is recorded and the +#: call proceeds. It is the default because cA2A is alpha and almost no caller can +#: attest yet, so a callee that refused unattested callers out of the box would be +#: a callee nobody could talk to -- and a control that breaks the common case gets +#: switched off and never switched back on. Strictness is opt-in, one rung at a +#: time: ``"any"`` requires an offer that appraises (software assurance is enough), +#: ``"hardware"`` requires the assurance to be hardware-backed. +#: +#: 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, because then a +#: misconfigured attestation path would look exactly like a caller that never had +#: one. +REQUIRE_NONE = "none" +REQUIRE_ANY = "any" +REQUIRE_HARDWARE = "hardware" + +REQUIREMENT_VALUES = frozenset({REQUIRE_NONE, REQUIRE_ANY, REQUIRE_HARDWARE}) def effective_scope( @@ -57,14 +99,51 @@ def enforce_peer_call( record_id: str, parent_record_hash: str | None = None, max_depth: int = 8, + caller_attestation: str = CALLER_NOT_OFFERED, ) -> PeerDecision: """Verify, intersect with local policy, enforce, and emit a provenance record. Raises ScopeNotPermitted if the requested capability is not in the effective scope, and the underlying CA2AError if the chain does not verify. On accept, returns a PeerDecision carrying the linked provenance record. + + ``caller_attestation`` is stamped onto whichever record this emits. Callers + that appraise the peer's runtime pass the outcome they established; + :func:`handle_peer_request` does exactly that. The default is the honest value + for a path that appraised nothing. """ effective = effective_scope(chain, policy, max_depth=max_depth) + return decide_capability( + chain, + requested_capability, + effective, + record_id=record_id, + parent_record_hash=parent_record_hash, + caller_attestation=caller_attestation, + ) + + +def decide_capability( + chain: list[DelegationCredential], + requested_capability: str, + effective: frozenset[str], + *, + record_id: str, + parent_record_hash: str | None = None, + caller_attestation: str = CALLER_NOT_OFFERED, +) -> PeerDecision: + """Enforce the capability against an already-computed effective scope. + + Split out of :func:`enforce_peer_call` so the inbound pipeline can appraise + the caller's runtime *between* verifying the chain and deciding the call, + without verifying the chain twice. That ordering is what lets every emitted + record -- allow or deny -- state the appraisal outcome accurately, rather than + a scope refusal claiming nothing was offered when something was. + + The chain must already be verified: this function emits provenance, and a + record built from an unverified credential is a claim about authority nobody + checked. + """ if requested_capability not in effective: reason = f"capability {requested_capability!r} is not in the effective scope" raise ScopeNotPermitted( @@ -80,9 +159,15 @@ def enforce_peer_call( requested_capability=requested_capability, effective_scope=effective, reason=reason, + caller_attestation=caller_attestation, ), ) - record = record_for(chain[-1], record_id=record_id, parent_record_hash=parent_record_hash) + record = record_for( + chain[-1], + record_id=record_id, + parent_record_hash=parent_record_hash, + caller_attestation=caller_attestation, + ) return PeerDecision( effective_scope=effective, granted_capability=requested_capability, @@ -104,6 +189,10 @@ class PeerRequest: record_id: str sealed_payload: bytes | None = None parent_record_hash: str | None = None + caller_offer: ChannelOffer | None = None + """The caller's own attested channel key, bound to a challenge this callee + issued. Optional: a caller that cannot attest omits it and, by default, is + still served. Present-and-unappraisable is refused; see :data:`REQUIRE_NONE`.""" @dataclass(frozen=True) @@ -114,6 +203,91 @@ class PeerResult: granted_capability: str record: DelegationRecord payload: bytes | None + caller_attestation: str = CALLER_NOT_OFFERED + """What the callee established about the caller's runtime. Also on ``record``, + where it is part of the portable evidence rather than just this return value.""" + + +def appraise_caller_runtime( + request: PeerRequest, + effective: frozenset[str], + *, + requirement: str = REQUIRE_NONE, + challenge_secret: bytes | None = None, + caller_verifier: Verifier | None = None, +) -> str: + """Appraise the caller's offer and return the recorded outcome, or refuse. + + Returns one of the ``CALLER_*`` values. Raises :class:`AttestationFailed`, + carrying a linked denial record, when the caller does not meet + ``requirement`` or presents an offer that does not appraise. The chain must + already be verified, for the same reason as :func:`decide_capability`: this + can emit provenance. + """ + if requirement not in REQUIREMENT_VALUES: + raise ConfigError( + f"require_caller_attestation must be one of {sorted(REQUIREMENT_VALUES)}, " + f"got {requirement!r}" + ) + + def refuse(reason: str, *, outcome: str, detail: str | None = None) -> AttestationFailed: + return AttestationFailed( + reason, + detail=detail, + record=denial_record_for( + request.chain[-1], + record_id=request.record_id, + parent_record_hash=request.parent_record_hash, + requested_capability=request.requested_capability, + effective_scope=effective, + reason=reason, + caller_attestation=outcome, + ), + ) + + if request.caller_offer is None: + if requirement == REQUIRE_NONE: + return CALLER_NOT_OFFERED + raise refuse( + "the callee requires caller attestation and the caller offered none", + outcome=CALLER_NOT_OFFERED, + detail=f"require_caller_attestation={requirement!r}", + ) + + if challenge_secret is None: + # The caller attested to a challenge, but this callee has no secret to + # check it against, so it cannot have issued that challenge. Refusing is + # the only honest answer: accepting would record an appraisal that never + # happened, and reporting "not offered" would discard a proof that was. + raise refuse( + "the caller offered an attestation but this callee issues no challenges", + outcome=CALLER_FAILED, + detail="no challenge secret is configured, so no offer can be appraised", + ) + + try: + peer = appraise_caller( + request.caller_offer, + challenge_secret=challenge_secret, + verifier=caller_verifier, + ) + except AttestationFailed as exc: + raise refuse( + f"the caller's attestation did not appraise: {exc}", + outcome=CALLER_FAILED, + detail=exc.detail, + ) from exc + + outcome = CALLER_HARDWARE if peer.assurance == "hardware" else CALLER_SOFTWARE_ONLY + if requirement == REQUIRE_HARDWARE and outcome != CALLER_HARDWARE: + # The appraisal succeeded, so record what was actually established rather + # than "failed"; the reason says it was the floor that refused the call. + raise refuse( + "the callee requires a hardware-attested caller", + outcome=outcome, + detail=f"the caller appraised at assurance {peer.assurance!r}", + ) + return outcome def handle_peer_request( @@ -122,22 +296,46 @@ def handle_peer_request( policy: Policy, enclave_private_key: X25519PrivateKey | None = None, max_depth: int = 8, + challenge_secret: bytes | None = None, + require_caller_attestation: str = REQUIRE_NONE, + caller_verifier: Verifier | None = None, ) -> PeerResult: """Run the full inbound pipeline for a parsed peer request. Verifies the delegation chain, intersects the delegated scope with the local - policy and enforces the requested capability, opens any sealed payload with - the enclave-bound key, and emits a linked provenance record. Fails closed: - any verification or authorization failure raises the relevant CA2AError and - no payload is returned. + policy, appraises what the caller is running, enforces the requested + capability, opens any sealed payload with the enclave-bound key, and emits a + linked provenance record. Fails closed: any verification, appraisal, or + authorization failure raises the relevant CA2AError and no payload is + returned. + + **The step order is the security property, not an implementation detail.** + The payload is sealed to this callee's channel key, so the callee *could* read + it the instant it arrives; appraisal therefore has to happen before + :func:`~ca2a_runtime.channel.open_sealed`, not merely before the response is + written. Appraising afterwards would mean an unattested caller had already had + its work done, and every guarantee this function offers would be a report on + something that already happened. ``tests/unit/test_mutual_attestation.py`` + asserts the payload is never opened when appraisal refuses, so swapping these + two calls fails the suite. """ - decision = enforce_peer_call( + effective = effective_scope(request.chain, policy, max_depth=max_depth) + + caller_attestation = appraise_caller_runtime( + request, + effective, + requirement=require_caller_attestation, + challenge_secret=challenge_secret, + caller_verifier=caller_verifier, + ) + + decision = decide_capability( request.chain, request.requested_capability, - policy=policy, + effective, record_id=request.record_id, parent_record_hash=request.parent_record_hash, - max_depth=max_depth, + caller_attestation=caller_attestation, ) payload: bytes | None = None @@ -151,4 +349,5 @@ def handle_peer_request( granted_capability=decision.granted_capability, record=decision.record, payload=payload, + caller_attestation=caller_attestation, ) diff --git a/src/ca2a_runtime/provenance.py b/src/ca2a_runtime/provenance.py index 1d3cf27..fedafb8 100644 --- a/src/ca2a_runtime/provenance.py +++ b/src/ca2a_runtime/provenance.py @@ -18,6 +18,19 @@ from ca2a_runtime.delegation.credential import DelegationCredential, canonical_bytes from ca2a_runtime.errors import ProvenanceLinkBroken +#: What the callee learned about the *caller's* own runtime at this hop. Always +#: present on a record, and deliberately four values rather than three: 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. +CALLER_NOT_OFFERED = "not_offered" +CALLER_FAILED = "failed" +CALLER_SOFTWARE_ONLY = "software-only" +CALLER_HARDWARE = "hardware" + +CALLER_ATTESTATION_VALUES = frozenset( + {CALLER_NOT_OFFERED, CALLER_FAILED, CALLER_SOFTWARE_ONLY, CALLER_HARDWARE} +) + @dataclass(frozen=True) class DelegationRecord: @@ -29,6 +42,16 @@ class DelegationRecord: evidence. The denial fields are omitted from the hashed body on an allow record, so allow-record hashes are unchanged from records emitted before denial records existed. + + ``caller_attestation`` is the exception to that omit-when-absent rule and is + always in the hashed body. It records what the callee established about the + caller's own runtime (see the ``CALLER_*`` values above). Omitting it when + nothing was appraised would mean an auditor could not tell a record emitted + by a peer that checked and found nothing from one emitted by a peer that + never checked, which is precisely the reading this field exists to prevent. + The cost is that it changes the hash of every record, including those emitted + before the field existed; the example DAGs in ``examples/`` were regenerated + for it. """ record_id: str @@ -40,6 +63,14 @@ class DelegationRecord: requested_capability: str | None = None effective_scope: frozenset[str] | None = None denial_reason: str | None = None + caller_attestation: str = CALLER_NOT_OFFERED + + def __post_init__(self) -> None: + if self.caller_attestation not in CALLER_ATTESTATION_VALUES: + raise ValueError( + f"caller_attestation must be one of {sorted(CALLER_ATTESTATION_VALUES)}, " + f"got {self.caller_attestation!r}" + ) def body(self) -> dict[str, Any]: """The hashed portion of the record.""" @@ -49,6 +80,7 @@ def body(self) -> dict[str, Any]: "subject": self.subject, "scope": sorted(self.scope), "parent_record_hash": self.parent_record_hash, + "caller_attestation": self.caller_attestation, } if self.decision != "allow": body["decision"] = self.decision @@ -74,14 +106,22 @@ def record_for( credential: DelegationCredential, record_id: str, parent_record_hash: str | None, + *, + caller_attestation: str = CALLER_NOT_OFFERED, ) -> DelegationRecord: - """Build the provenance record a hop emits for a delegation credential.""" + """Build the provenance record a hop emits for a delegation credential. + + ``caller_attestation`` defaults to ``"not_offered"``, which is the honest + value for every path that is not an inbound peer call over a transport: a + locally built chain was never offered an attestation to appraise. + """ return DelegationRecord( record_id=record_id, credential_id=credential.credential_id, subject=credential.subject, scope=credential.scope, parent_record_hash=parent_record_hash, + caller_attestation=caller_attestation, ) @@ -93,6 +133,7 @@ def denial_record_for( requested_capability: str, effective_scope: frozenset[str], reason: str, + caller_attestation: str = CALLER_NOT_OFFERED, ) -> DelegationRecord: """Build the provenance record a hop emits when it refuses a call. @@ -100,6 +141,10 @@ def denial_record_for( the chain sees the refusal in place rather than a gap. It states the capability that was requested and the effective scope it fell outside of, which is what makes the refusal checkable offline against the signed chain. + + A refusal driven by the caller's attestation rather than by scope passes + ``caller_attestation="failed"``, so the record says which of the two checks + stopped the call. """ return DelegationRecord( record_id=record_id, @@ -111,6 +156,7 @@ def denial_record_for( requested_capability=requested_capability, effective_scope=effective_scope, denial_reason=reason, + caller_attestation=caller_attestation, ) diff --git a/src/ca2a_runtime/transport/a2a_adapter.py b/src/ca2a_runtime/transport/a2a_adapter.py index ae59ded..9894058 100644 --- a/src/ca2a_runtime/transport/a2a_adapter.py +++ b/src/ca2a_runtime/transport/a2a_adapter.py @@ -14,17 +14,20 @@ import re from typing import Any +from ca2a_runtime.attestation import ChannelOffer from ca2a_runtime.delegation.credential import DelegationCredential from ca2a_runtime.errors import InvalidCredential, TransportError from ca2a_runtime.peer import PeerRequest from ca2a_runtime.transport.constants import ( CA2A_METADATA_KEYS, + KEY_CALLER_OFFER, KEY_DELEGATION_CHAIN, KEY_PARENT_RECORD_HASH, KEY_RECORD_ID, KEY_REQUESTED_CAPABILITY, KEY_SEALED_PAYLOAD, ) +from ca2a_runtime.transport.wire import parse_channel_offer, serialize_channel_offer # Unpadded base64url alphabet only; padding is added during decode, so an # embedded "=" (or any other character) is rejected as malformed. @@ -178,12 +181,21 @@ def parse_peer_request( raise TransportError("sealed_payload must be a base64url string or null") sealed_payload = _b64url_decode(sealed_raw) + # Optional, unlike parent_record_hash: an absent key means a caller that does + # not attest, which is the common case and must parse. A key that is present + # but not an offer is malformed and fails closed rather than being dropped, + # so a caller cannot get itself treated as unattested by sending rubbish. + caller_offer: ChannelOffer | None = None + if KEY_CALLER_OFFER in meta and meta[KEY_CALLER_OFFER] is not None: + caller_offer = parse_channel_offer(meta[KEY_CALLER_OFFER]) + return PeerRequest( chain=chain, requested_capability=capability, record_id=record_id, sealed_payload=sealed_payload, parent_record_hash=parent_record_hash, + caller_offer=caller_offer, ) @@ -237,4 +249,8 @@ def attach_ca2a_metadata( meta.pop(KEY_SEALED_PAYLOAD, None) else: meta[KEY_SEALED_PAYLOAD] = _b64url_encode(request.sealed_payload) + if request.caller_offer is None: + meta.pop(KEY_CALLER_OFFER, None) + else: + meta[KEY_CALLER_OFFER] = serialize_channel_offer(request.caller_offer) return out diff --git a/src/ca2a_runtime/transport/client.py b/src/ca2a_runtime/transport/client.py index 83d8c25..9118243 100644 --- a/src/ca2a_runtime/transport/client.py +++ b/src/ca2a_runtime/transport/client.py @@ -13,12 +13,21 @@ import urllib.error import urllib.parse import urllib.request +from dataclasses import dataclass from typing import Any -from ca2a_runtime.attestation import VerifiedPeer, Verifier, seal_to_peer, verify_offer +from ca2a_runtime.attestation import ( + ChannelOffer, + VerifiedPeer, + Verifier, + offer_channel, + seal_to_peer, + verify_offer, +) from ca2a_runtime.delegation.credential import DelegationCredential -from ca2a_runtime.errors import CA2AError, TransportError +from ca2a_runtime.errors import AttestationFailed, CA2AError, TransportError from ca2a_runtime.peer import PeerRequest +from ca2a_runtime.tee.base import BaseProvider from ca2a_runtime.transport import a2a_adapter, wire from ca2a_runtime.transport.server import CHANNEL_PATH, TASK_PATH @@ -59,11 +68,38 @@ def _post_json(url: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: return exc.code, json.loads(exc.read()) +@dataclass(frozen=True) +class Handshake: + """What one round trip to the handshake endpoint yields. + + ``peer`` is the appraised callee. ``challenge`` is the callee's half of a + mutual exchange, and is None when the callee issued none -- an older or + deliberately one-directional peer, which is still perfectly usable. + """ + + peer: VerifiedPeer + challenge: str | None + + +def handshake(base_url: str, *, verifier: Verifier | None = None) -> Handshake: + """Fetch the peer's attested channel key, verify it, and keep its challenge. + + One round trip serves both directions: the nonce is the caller's own and is + what makes the callee's report live, and the challenge that comes back is what + would make the caller's report live in the other direction. + """ + nonce = secrets.token_hex(16) + body = _get_json(f"{base_url}{CHANNEL_PATH}?nonce={nonce}") + offer = wire.parse_channel_offer(body) + return Handshake( + peer=verify_offer(offer, expected_nonce=nonce, verifier=verifier), + challenge=wire.parse_challenge(body), + ) + + def fetch_verified_peer(base_url: str, *, verifier: Verifier | None = None) -> VerifiedPeer: """Fetch the peer's attested channel key and verify it under a fresh nonce.""" - nonce = secrets.token_hex(16) - offer = wire.parse_channel_offer(_get_json(f"{base_url}{CHANNEL_PATH}?nonce={nonce}")) - return verify_offer(offer, expected_nonce=nonce, verifier=verifier) + return handshake(base_url, verifier=verifier).peer def send_task( @@ -75,22 +111,50 @@ def send_task( payload: bytes | None = None, verifier: Verifier | None = None, parent_record_hash: str | None = None, + caller_provider: BaseProvider | None = None, ) -> dict[str, Any]: """Run the caller side end to end: verify the peer, seal the payload, send the task. + Pass ``caller_provider`` to make the exchange mutual: the caller binds its own + channel key into a report under the challenge the callee issued, so the callee + learns what the caller is running rather than only what it is allowed to ask + for. Omit it and the call is one-directional, which is what every caller did + before and what most callees still accept. + Returns the parsed response body on acceptance. Raises a :class:`CA2AError` carrying the peer's error code and message on any peer-side failure. """ sealed: bytes | None = None - if payload is not None: - peer = fetch_verified_peer(base_url, verifier=verifier) - sealed = seal_to_peer(peer, payload) + caller_offer: ChannelOffer | None = None + if payload is not None or caller_provider is not None: + hello = handshake(base_url, verifier=verifier) + if payload is not None: + sealed = seal_to_peer(hello.peer, payload) + if caller_provider is not None: + if hello.challenge is None: + # Attesting under a nonce we picked ourselves would prove only + # that we can produce a report, not that we produced one for this + # exchange. Better to say the peer does not support this than to + # send something that looks like mutual attestation and is not. + raise AttestationFailed( + "the peer issued no challenge, so the caller cannot attest to this exchange", + detail="the callee's handshake response carried no 'challenge' field", + ) + # The private half goes unused today: response sealing was withdrawn + # because nothing confidential comes back (the response is the + # provenance record, which has to stay readable). The key's job here + # is to be what the report binds, which is what makes the caller's + # measurement live rather than replayed. + _caller_private_key, caller_offer = offer_channel( + caller_provider, nonce=hello.challenge + ) request = PeerRequest( chain=chain, requested_capability=requested_capability, record_id=record_id, sealed_payload=sealed, parent_record_hash=parent_record_hash, + caller_offer=caller_offer, ) message = a2a_adapter.attach_ca2a_metadata({}, request) status, body = _post_json(f"{base_url}{TASK_PATH}", message) diff --git a/src/ca2a_runtime/transport/constants.py b/src/ca2a_runtime/transport/constants.py index f4b4d4a..64957db 100644 --- a/src/ca2a_runtime/transport/constants.py +++ b/src/ca2a_runtime/transport/constants.py @@ -16,6 +16,12 @@ KEY_PARENT_RECORD_HASH = f"{EXTENSION_URI}/parent_record_hash" KEY_SEALED_PAYLOAD = f"{EXTENSION_URI}/sealed_payload" +#: The caller's own attested channel key, bound to a challenge the callee issued. +#: Optional, unlike ``parent_record_hash``: a caller that cannot attest omits the +#: key entirely rather than sending null, and is still served by default. Making it +#: required would break every caller that exists today. +KEY_CALLER_OFFER = f"{EXTENSION_URI}/caller_offer" + CA2A_METADATA_KEYS = frozenset( { KEY_DELEGATION_CHAIN, @@ -23,5 +29,6 @@ KEY_RECORD_ID, KEY_PARENT_RECORD_HASH, KEY_SEALED_PAYLOAD, + KEY_CALLER_OFFER, } ) diff --git a/src/ca2a_runtime/transport/server.py b/src/ca2a_runtime/transport/server.py index 26430c1..00b83c7 100644 --- a/src/ca2a_runtime/transport/server.py +++ b/src/ca2a_runtime/transport/server.py @@ -65,8 +65,17 @@ def do_GET(self) -> None: if not nonces: self._send_json(400, {"error": {"code": "BAD_REQUEST", "message": "nonce required"}}) return - offer = self._node().offer(nonces[0]) - self._send_json(200, wire.serialize_channel_offer(offer)) + node = self._node() + offer = node.offer(nonces[0]) + # The handshake is where both directions get what they need in one round + # trip: the caller gets the key to seal to, and the challenge it must bind + # its own key into if it wants to be appraised. Issuing one costs nothing + # and stores nothing, so it is offered unconditionally; a caller that + # ignores it is still served. + self._send_json( + 200, + wire.serialize_channel_offer(offer, challenge=node.issue_challenge()), + ) def do_POST(self) -> None: if urlparse(self.path).path != TASK_PATH: diff --git a/src/ca2a_runtime/transport/wire.py b/src/ca2a_runtime/transport/wire.py index 1e44cd0..01b47b5 100644 --- a/src/ca2a_runtime/transport/wire.py +++ b/src/ca2a_runtime/transport/wire.py @@ -27,11 +27,17 @@ def _record_to_dict(record: DelegationRecord) -> dict[str, Any]: def serialize_peer_result(result: PeerResult) -> dict[str, Any]: """Serialize an accepted result. The opened payload is the callee's confidential input and is never echoed; the response returns the provenance - record (with its hash) so the caller can chain the next hop.""" + record (with its hash) so the caller can chain the next hop. + + ``caller_attestation`` is also inside ``record``, where it is hashed and + therefore portable evidence. It is repeated at the top level so a caller can + see what the callee made of it without unpacking the record, and the two can + never disagree because both are read from the same result.""" return { "accepted": True, "effective_scope": sorted(result.effective_scope), "granted_capability": result.granted_capability, + "caller_attestation": result.caller_attestation, "record": _record_to_dict(result.record), } @@ -48,9 +54,21 @@ def serialize_error(err: CA2AError) -> dict[str, Any]: } -def serialize_channel_offer(offer: ChannelOffer) -> dict[str, Any]: - """Serialize a channel offer (the callee's attested channel key).""" - return { +def serialize_channel_offer( + offer: ChannelOffer, *, challenge: str | None = None +) -> dict[str, Any]: + """Serialize a channel offer (a peer's attested channel key). + + Used in both directions, which is why it lives here rather than beside either + one: the callee returns it from the handshake endpoint, and the caller nests + the same shape in its ``caller_offer`` metadata. One codec, so the two + encodings cannot drift apart. + + ``challenge`` is the callee's half of a mutual exchange and is omitted when + the callee issues none, so an older caller sees exactly the response it saw + before. A caller that does not understand the field simply does not attest. + """ + body: dict[str, Any] = { "channel_public_key": offer.channel_public_key, "attestation": { "platform": offer.report.platform, @@ -59,10 +77,17 @@ def serialize_channel_offer(offer: ChannelOffer) -> dict[str, Any]: "nonce": offer.report.nonce, }, } + if challenge is not None: + body["challenge"] = challenge + return body def parse_channel_offer(data: dict[str, Any]) -> ChannelOffer: """Parse a channel offer received from a peer. Fails closed.""" + if not isinstance(data, dict): + raise TransportError( + "malformed channel offer", detail=f"expected an object, got {type(data).__name__}" + ) try: public_key = str(data["channel_public_key"]) att = data["attestation"] @@ -75,3 +100,21 @@ def parse_channel_offer(data: dict[str, Any]) -> ChannelOffer: except (KeyError, TypeError) as exc: raise TransportError("malformed channel offer", detail=str(exc)) from exc return ChannelOffer(channel_public_key=public_key, report=report) + + +def parse_challenge(data: dict[str, Any]) -> str | None: + """Read the callee's challenge out of a handshake response. + + Returns None when the callee issued none, which is not an error: a callee + that does not want mutual attestation is a callee the caller can still talk + to. A present-but-non-string value is malformed and fails closed. + """ + if "challenge" not in data: + return None + challenge = data["challenge"] + if not isinstance(challenge, str) or not challenge: + raise TransportError( + "challenge must be a non-empty string when present", + detail=f"got {type(challenge).__name__}", + ) + return challenge diff --git a/tests/unit/test_committed_examples_verify.py b/tests/unit/test_committed_examples_verify.py new file mode 100644 index 0000000..7167508 --- /dev/null +++ b/tests/unit/test_committed_examples_verify.py @@ -0,0 +1,75 @@ +"""The example artifacts a reader clones must verify as committed. + +The demos regenerate ``chain.json`` / ``dag.json`` when they run, which means the +existing example tests verify whatever the demo just wrote, not what is in the +repository. That gap is not hypothetical: changing the hashed record body left +``examples/cross-operator-delegation/dag.json`` broken on disk, with every test +still green and the README still quoting ``ca2a verify-dag`` as working. + +So these tests read the *committed* blobs out of git rather than the working +tree. A demo run cannot make them pass, and a change to the record body that +forgets to regenerate the examples fails here. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ca2a_runtime.cli import main as cli_main + +REPO_ROOT = Path(__file__).resolve().parents[2] + +EXAMPLES = [ + "examples/cross-operator-delegation", + "examples/rejection-with-proof", +] + + +def _committed(path: str) -> str | None: + """The blob at HEAD for ``path``, or None if git cannot tell us.""" + try: + out = subprocess.run( # noqa: S603 + ["git", "show", f"HEAD:{path}"], # noqa: S607 + capture_output=True, + text=True, + cwd=REPO_ROOT, + check=False, + ) + except OSError: + return None + return out.stdout if out.returncode == 0 else None + + +@pytest.mark.parametrize("example", EXAMPLES) +def test_committed_dag_verifies(example: str, tmp_path: Path) -> None: + dag = _committed(f"{example}/dag.json") + chain = _committed(f"{example}/chain.json") + if dag is None or chain is None: + pytest.skip("git is unavailable or the example is not committed yet") + + dag_path = tmp_path / "dag.json" + chain_path = tmp_path / "chain.json" + dag_path.write_text(dag, encoding="utf-8") + chain_path.write_text(chain, encoding="utf-8") + + assert cli_main(["verify-dag", "--dag", str(dag_path), "--chain", str(chain_path)]) == 0 + + +@pytest.mark.parametrize("example", EXAMPLES) +def test_committed_dag_states_an_attestation_outcome_on_every_record(example: str) -> None: + """Every record says what the emitter established about its caller. + + Absence is the reading this field exists to prevent, so a committed record + without it is a record an auditor would have to guess about. + """ + blob = _committed(f"{example}/dag.json") + if blob is None: + pytest.skip("git is unavailable or the example is not committed yet") + records = json.loads(blob)["records"] + assert records + for record in records: + assert "caller_attestation" in record, record.get("record_id") diff --git a/tests/unit/test_live_call.py b/tests/unit/test_live_call.py index 462aaec..ff50d1e 100644 --- a/tests/unit/test_live_call.py +++ b/tests/unit/test_live_call.py @@ -5,7 +5,11 @@ from __future__ import annotations +import json +import socket import threading +import urllib.error +import urllib.request import pytest @@ -24,6 +28,22 @@ from ca2a_runtime.transport import a2a_adapter, client, server, wire +def _post_bytes(url: str, raw: bytes) -> tuple[int, dict]: + """POST raw bytes, bypassing the client's JSON encoding. + + The client only ever sends well-formed messages, so testing what the server + does with a body it should refuse needs to go around it. + """ + req = urllib.request.Request( + url, data=raw, headers={"Content-Type": "application/json"}, method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read()) + + @pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://host/x", "gopher://host"]) def test_client_rejects_non_http_url(url: str) -> None: with pytest.raises(TransportError, match="non-HTTP"): @@ -145,3 +165,50 @@ def test_http_live_call_end_to_end() -> None: finally: srv.shutdown() srv.server_close() + + +def test_oversized_body_is_refused_at_the_declared_bound() -> None: + """The 1 MiB cap, exercised rather than asserted in a comment. + + A bound with no test above it is a bound nobody has ever crossed. This one + declares an oversized ``Content-Length`` and sends only a few bytes, which is + what the guard actually inspects: the server refuses on the declared length + *before* reading, so an oversized body is never buffered at all. That is also + why the check has to go through a raw socket -- sending a real 1 MiB body + races the server's early refusal and the client sees a connection abort + rather than the 400 it sent. + """ + node = PeerNode(LocalPolicy.of({"read"})) + srv = server.serve(node, host="127.0.0.1", port=0) + port = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + request = ( + f"POST {server.TASK_PATH} HTTP/1.1\r\n" + f"Host: 127.0.0.1:{port}\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {server._MAX_BODY + 1}\r\n" + "Connection: close\r\n\r\n" + ).encode() + b'{"a":1}' + + with socket.create_connection(("127.0.0.1", port), timeout=10) as sock: + sock.sendall(request) + chunks = [] + while True: + chunk = sock.recv(4096) + if not chunk: + break + chunks.append(chunk) + response = b"".join(chunks) + assert b"400" in response.split(b"\r\n", 1)[0] + assert b"BAD_REQUEST" in response + + # and the boundary still opens: a body under the cap is read and parsed, + # then fails for its own reasons rather than for its size + url = f"http://127.0.0.1:{port}{server.TASK_PATH}" + status, body = _post_bytes(url, b'{"not":"a ca2a message"}') + assert status == 400 + assert body["error"]["code"] == "TRANSPORT_ERROR" + finally: + srv.shutdown() + srv.server_close() diff --git a/tests/unit/test_mutual_attestation.py b/tests/unit/test_mutual_attestation.py new file mode 100644 index 0000000..5dcd34c --- /dev/null +++ b/tests/unit/test_mutual_attestation.py @@ -0,0 +1,491 @@ +"""Mutual attestation: the callee appraising what the *caller* is running. + +The delegation chain says what a caller is allowed to ask for. It says nothing +about what the caller is, and until this the callee never asked. These tests +cover the callee-issued challenge, the caller offer bound to it, the requirement +ladder, and -- above all -- the ordering: appraisal happens before the sealed +payload is opened, so an unattested caller never gets its work done. See +``docs/spec/mutual-attestation.md``. +""" + +from __future__ import annotations + +import threading +import time + +import pytest + +from ca2a_runtime import challenge as challenge_mod +from ca2a_runtime import peer as peer_mod +from ca2a_runtime.attestation import ChannelOffer, appraise_caller, seal_to_peer, verify_offer +from ca2a_runtime.delegation.credential import DelegationCredential, new_keypair +from ca2a_runtime.errors import AttestationFailed, CA2AError, ConfigError, TransportError +from ca2a_runtime.node import PeerNode +from ca2a_runtime.peer import ( + REQUIRE_ANY, + REQUIRE_HARDWARE, + REQUIRE_NONE, + PeerRequest, + handle_peer_request, +) +from ca2a_runtime.policy import LocalPolicy +from ca2a_runtime.provenance import ( + CALLER_FAILED, + CALLER_HARDWARE, + CALLER_NOT_OFFERED, + CALLER_SOFTWARE_ONLY, +) +from ca2a_runtime.tee.base import AttestationReport +from ca2a_runtime.tee.software import SoftwareProvider +from ca2a_runtime.transport import a2a_adapter, client, server, wire +from ca2a_runtime.transport.constants import KEY_CALLER_OFFER + +POLICY = LocalPolicy.of({"read"}) + + +def _chain() -> list[DelegationCredential]: + root_priv, root_pub = new_keypair() + subject_pub = new_keypair()[1] + return [ + DelegationCredential( + credential_id="c0", + issuer=root_pub, + subject=subject_pub, + scope=frozenset({"read", "write"}), + depth=0, + ).sign(root_priv) + ] + + +def _caller_offer(challenge: str, *, platform: str = "software-only") -> ChannelOffer: + """A caller's own attested channel key, bound to ``challenge``.""" + public_key = SoftwareProvider().attest("x", "y").public_key # a well-formed key string + return ChannelOffer( + channel_public_key=public_key, + report=AttestationReport( + platform=platform, + measurement="caller-measurement", + public_key=public_key, + nonce=challenge, + ), + ) + + +def _request( + *, + capability: str = "read", + sealed: bytes | None = None, + caller_offer: ChannelOffer | None = None, +) -> PeerRequest: + return PeerRequest( + chain=_chain(), + requested_capability=capability, + record_id="r0", + sealed_payload=sealed, + caller_offer=caller_offer, + ) + + +# -------------------------------------------------------------------------- +# The ordering property. This is the whole point of the change. +# -------------------------------------------------------------------------- + + +class _OpenSpy: + """Stands in for ``open_sealed`` and remembers whether it ran.""" + + def __init__(self) -> None: + self.calls = 0 + + def __call__(self, sealed: bytes, key: object) -> bytes: + self.calls += 1 + return b"opened" + + +@pytest.mark.parametrize( + ("requirement", "offer_for"), + [ + # required and absent + (REQUIRE_ANY, None), + # required, present, and forged under a secret the callee never held + (REQUIRE_ANY, "forged"), + # present and valid, but below the floor the callee set + (REQUIRE_HARDWARE, "valid"), + ], +) +def test_sealed_payload_is_never_opened_when_appraisal_refuses( + monkeypatch: pytest.MonkeyPatch, requirement: str, offer_for: str | None +) -> None: + """Swap the appraisal and open_sealed calls and this test fails. + + The payload is sealed to the callee's own channel key, so the callee *can* + read it the moment it arrives. Appraising after opening would mean the + unattested caller had already had its work done, and every guarantee would be + a report on something that already happened. + """ + secret = challenge_mod.generate_secret() + spy = _OpenSpy() + monkeypatch.setattr(peer_mod, "open_sealed", spy) + + offer = None + if offer_for == "forged": + other_secret = challenge_mod.generate_secret() + offer = _caller_offer(challenge_mod.issue_challenge(other_secret)) + elif offer_for == "valid": + offer = _caller_offer(challenge_mod.issue_challenge(secret)) + + with pytest.raises(AttestationFailed): + handle_peer_request( + _request(sealed=b"ciphertext", caller_offer=offer), + policy=POLICY, + enclave_private_key=object(), # never reached, so never used + challenge_secret=secret, + require_caller_attestation=requirement, + caller_verifier=lambda report, nonce: "m", + ) + + assert spy.calls == 0, "the callee opened the payload before appraising the caller" + + +def test_payload_is_opened_once_the_caller_does_appraise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The mirror of the above: appraisal passing must not block the real work.""" + secret = challenge_mod.generate_secret() + spy = _OpenSpy() + monkeypatch.setattr(peer_mod, "open_sealed", spy) + + result = handle_peer_request( + _request( + sealed=b"ciphertext", + caller_offer=_caller_offer(challenge_mod.issue_challenge(secret)), + ), + policy=POLICY, + enclave_private_key=object(), + challenge_secret=secret, + require_caller_attestation=REQUIRE_ANY, + ) + assert spy.calls == 1 + assert result.payload == b"opened" + assert result.caller_attestation == CALLER_SOFTWARE_ONLY + + +# -------------------------------------------------------------------------- +# The requirement ladder +# -------------------------------------------------------------------------- + + +def test_default_serves_a_caller_that_offers_nothing() -> None: + """The common case. A callee that refused this by default is one nobody can use.""" + result = handle_peer_request(_request(), policy=POLICY) + assert result.caller_attestation == CALLER_NOT_OFFERED + assert result.record.caller_attestation == CALLER_NOT_OFFERED + assert result.record.decision == "allow" + + +def test_default_records_a_software_caller_that_does_attest() -> None: + secret = challenge_mod.generate_secret() + result = handle_peer_request( + _request(caller_offer=_caller_offer(challenge_mod.issue_challenge(secret))), + policy=POLICY, + challenge_secret=secret, + ) + assert result.caller_attestation == CALLER_SOFTWARE_ONLY + assert result.record.caller_attestation == CALLER_SOFTWARE_ONLY + + +def test_require_any_refuses_a_caller_that_offers_nothing() -> None: + secret = challenge_mod.generate_secret() + with pytest.raises(AttestationFailed) as exc_info: + handle_peer_request( + _request(), + policy=POLICY, + challenge_secret=secret, + require_caller_attestation=REQUIRE_ANY, + ) + record = exc_info.value.record + assert record is not None + assert record.decision == "deny" + assert record.caller_attestation == CALLER_NOT_OFFERED + + +def test_require_hardware_refuses_a_software_caller_and_says_what_it_found() -> None: + """The refusal records "software-only", not "failed": the appraisal worked.""" + secret = challenge_mod.generate_secret() + with pytest.raises(AttestationFailed) as exc_info: + handle_peer_request( + _request(caller_offer=_caller_offer(challenge_mod.issue_challenge(secret))), + policy=POLICY, + challenge_secret=secret, + require_caller_attestation=REQUIRE_HARDWARE, + caller_verifier=lambda report, nonce: "m", + ) + assert exc_info.value.record.caller_attestation == CALLER_SOFTWARE_ONLY + + +def test_require_hardware_accepts_a_verified_hardware_caller() -> None: + secret = challenge_mod.generate_secret() + offer = _caller_offer(challenge_mod.issue_challenge(secret), platform="sev-snp") + result = handle_peer_request( + _request(caller_offer=offer), + policy=POLICY, + challenge_secret=secret, + require_caller_attestation=REQUIRE_HARDWARE, + caller_verifier=lambda report, nonce: "caller-measurement", + ) + assert result.caller_attestation == CALLER_HARDWARE + assert result.record.caller_attestation == CALLER_HARDWARE + + +def test_unknown_requirement_value_is_a_config_error() -> None: + with pytest.raises(ConfigError, match="require_caller_attestation"): + handle_peer_request( + _request(), policy=POLICY, require_caller_attestation="strict-ish" + ) + + +# -------------------------------------------------------------------------- +# A broken proof is not the same fact as no proof +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("requirement", [REQUIRE_NONE, REQUIRE_ANY, REQUIRE_HARDWARE]) +def test_a_forged_challenge_is_refused_at_every_rung(requirement: str) -> None: + """Including at "none". Demanding nothing means accepting a caller that proves + nothing; it does not mean accepting a broken proof.""" + secret = challenge_mod.generate_secret() + forged = challenge_mod.issue_challenge(challenge_mod.generate_secret()) + with pytest.raises(AttestationFailed) as exc_info: + handle_peer_request( + _request(caller_offer=_caller_offer(forged)), + policy=POLICY, + challenge_secret=secret, + require_caller_attestation=requirement, + caller_verifier=lambda report, nonce: "m", + ) + assert exc_info.value.record.caller_attestation == CALLER_FAILED + + +def test_an_expired_challenge_is_refused() -> None: + secret = challenge_mod.generate_secret() + expired = challenge_mod.issue_challenge(secret, ttl_seconds=1) + # Rather than sleeping: the challenge encodes its own expiry, so verifying it + # against a later clock is the same code path a real timeout takes. + with pytest.raises(AttestationFailed, match="expired"): + challenge_mod.verify_challenge(secret, expired, now=int(time.time()) + 5) + + +def test_a_challenge_from_another_instance_does_not_verify() -> None: + """The per-process secret, stated as a test: two nodes do not share challenges. + + This is the cost of the stateless scheme that was chosen deliberately. A + deployment behind a load balancer must either pin the handshake and the task + to one instance or share a secret between them. + """ + node_a, node_b = PeerNode(POLICY), PeerNode(POLICY) + offer = _caller_offer(node_a.issue_challenge()) + message = a2a_adapter.attach_ca2a_metadata( + {}, _request(caller_offer=offer) + ) + node_b.require_caller_attestation = REQUIRE_ANY + with pytest.raises(AttestationFailed): + node_b.handle(message) + + +def test_an_offer_to_a_callee_that_issues_no_challenges_is_refused() -> None: + """Present-but-unappraisable must not be silently downgraded to absent.""" + with pytest.raises(AttestationFailed, match="issues no challenges") as exc_info: + handle_peer_request( + _request(caller_offer=_caller_offer("some.challenge.value.here")), + policy=POLICY, + challenge_secret=None, + ) + assert exc_info.value.record.caller_attestation == CALLER_FAILED + + +def test_appraise_caller_rejects_a_report_that_binds_a_different_key() -> None: + secret = challenge_mod.generate_secret() + good = _caller_offer(challenge_mod.issue_challenge(secret)) + swapped = ChannelOffer(channel_public_key="a-different-key", report=good.report) + with pytest.raises(AttestationFailed, match="does not bind"): + appraise_caller(swapped, challenge_secret=secret) + + +def test_a_hardware_caller_report_without_a_verifier_fails_closed() -> None: + """The callee must not take a hardware claim on trust just because it is bold.""" + secret = challenge_mod.generate_secret() + offer = _caller_offer(challenge_mod.issue_challenge(secret), platform="tdx") + with pytest.raises(AttestationFailed, match="requires a hardware verifier"): + appraise_caller(offer, challenge_secret=secret, verifier=None) + + +# -------------------------------------------------------------------------- +# Configuration that could only ever fail should fail at construction +# -------------------------------------------------------------------------- + + +def test_node_rejects_hardware_requirement_without_a_verifier() -> None: + with pytest.raises(ConfigError, match="caller_verifier"): + PeerNode(POLICY, require_caller_attestation=REQUIRE_HARDWARE) + + +def test_node_rejects_an_unknown_requirement() -> None: + with pytest.raises(ConfigError): + PeerNode(POLICY, require_caller_attestation="yes-please") + + +# -------------------------------------------------------------------------- +# Wire format +# -------------------------------------------------------------------------- + + +def test_caller_offer_round_trips_through_a2a_metadata() -> None: + offer = _caller_offer("v1.123.abc.def") + request = _request(caller_offer=offer) + parsed = a2a_adapter.parse_peer_request(a2a_adapter.attach_ca2a_metadata({}, request)) + assert parsed.caller_offer is not None + assert parsed.caller_offer.channel_public_key == offer.channel_public_key + assert parsed.caller_offer.report.nonce == "v1.123.abc.def" + assert parsed.caller_offer.report.platform == offer.report.platform + + +def test_absent_caller_offer_parses_as_no_offer() -> None: + parsed = a2a_adapter.parse_peer_request( + a2a_adapter.attach_ca2a_metadata({}, _request()) + ) + assert parsed.caller_offer is None + + +def test_a_malformed_caller_offer_fails_closed() -> None: + """A caller cannot get itself treated as unattested by sending rubbish.""" + message = a2a_adapter.attach_ca2a_metadata({}, _request()) + message["metadata"][KEY_CALLER_OFFER] = {"channel_public_key": "k"} # no attestation + with pytest.raises(TransportError, match="malformed channel offer"): + a2a_adapter.parse_peer_request(message) + + +def test_challenge_rides_the_handshake_response() -> None: + node = PeerNode(POLICY) + body = wire.serialize_channel_offer(node.offer("n"), challenge=node.issue_challenge()) + assert wire.parse_challenge(body) is not None + # and the offer still parses exactly as it did before the field existed + assert wire.parse_channel_offer(body).channel_public_key == node.channel_public_key + + +def test_a_handshake_without_a_challenge_is_not_an_error() -> None: + """A one-directional callee stays usable; the caller simply does not attest.""" + node = PeerNode(POLICY) + assert wire.parse_challenge(wire.serialize_channel_offer(node.offer("n"))) is None + + +@pytest.mark.parametrize("bad", [42, "", None, {"nested": True}]) +def test_a_present_but_unusable_challenge_is_malformed(bad: object) -> None: + with pytest.raises(TransportError, match="challenge must be"): + wire.parse_challenge({"challenge": bad}) + + +def test_result_states_the_outcome_at_the_top_level_and_in_the_record() -> None: + secret = challenge_mod.generate_secret() + result = handle_peer_request( + _request(caller_offer=_caller_offer(challenge_mod.issue_challenge(secret))), + policy=POLICY, + challenge_secret=secret, + ) + body = wire.serialize_peer_result(result) + assert body["caller_attestation"] == CALLER_SOFTWARE_ONLY + assert body["record"]["caller_attestation"] == CALLER_SOFTWARE_ONLY + + +# -------------------------------------------------------------------------- +# End to end over the reference HTTP transport +# -------------------------------------------------------------------------- + + +def _serve(node: PeerNode): + srv = server.serve(node, host="127.0.0.1", port=0) + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv, f"http://127.0.0.1:{srv.server_address[1]}" + + +def test_http_mutual_call_end_to_end() -> None: + """The caller attests to the callee's challenge, over the real transport.""" + node = PeerNode(POLICY, require_caller_attestation=REQUIRE_ANY) + srv, base = _serve(node) + try: + body = client.send_task( + base, + _chain(), + "read", + "r0", + payload=b"confidential", + caller_provider=SoftwareProvider(), + ) + assert body["accepted"] is True + assert body["caller_attestation"] == CALLER_SOFTWARE_ONLY + finally: + srv.shutdown() + srv.server_close() + + +def test_http_unattested_caller_is_refused_by_a_callee_that_requires_one() -> None: + node = PeerNode(POLICY, require_caller_attestation=REQUIRE_ANY) + srv, base = _serve(node) + try: + with pytest.raises(CA2AError) as exc_info: + client.send_task(base, _chain(), "read", "r0", payload=b"confidential") + assert exc_info.value.code == "ATTESTATION_FAILED" + assert exc_info.value.http_status == 412 + finally: + srv.shutdown() + srv.server_close() + + +def test_http_unattested_caller_is_served_by_default() -> None: + """The adoption case: an old caller against a new callee, unchanged.""" + srv, base = _serve(PeerNode(POLICY)) + try: + body = client.send_task(base, _chain(), "read", "r0", payload=b"confidential") + assert body["accepted"] is True + assert body["caller_attestation"] == CALLER_NOT_OFFERED + finally: + srv.shutdown() + srv.server_close() + + +def test_client_refuses_to_fake_mutuality_against_a_silent_callee() -> None: + """A caller that opted in must not quietly fall back to a nonce it chose itself.""" + node = PeerNode(POLICY) + srv = server.serve(node, host="127.0.0.1", port=0) + # Serve a handshake with the challenge stripped, as an older callee would. + original = wire.serialize_channel_offer + + def _no_challenge(offer, *, challenge=None): + return original(offer) + + threading.Thread(target=srv.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{srv.server_address[1]}" + try: + wire.serialize_channel_offer = _no_challenge # type: ignore[assignment] + with pytest.raises(AttestationFailed, match="issued no challenge"): + client.send_task( + base, _chain(), "read", "r0", caller_provider=SoftwareProvider() + ) + finally: + wire.serialize_channel_offer = original # type: ignore[assignment] + srv.shutdown() + srv.server_close() + + +def test_sealed_payload_still_reaches_a_callee_that_appraises_the_caller() -> None: + """Mutual attestation must not break the thing it wraps.""" + node = PeerNode(POLICY, require_caller_attestation=REQUIRE_ANY) + peer = verify_offer(node.offer("n"), expected_nonce="n") + sealed = seal_to_peer(peer, b"confidential task input") + offer = _caller_offer(node.issue_challenge()) + result = node.handle( + a2a_adapter.attach_ca2a_metadata( + {}, _request(sealed=sealed, caller_offer=offer) + ) + ) + assert result.payload == b"confidential task input" + assert result.caller_attestation == CALLER_SOFTWARE_ONLY