From 36d7e7f57d66beda0c0220c83201747142ea503a Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sun, 19 Jul 2026 21:49:14 -0700 Subject: [PATCH 1/3] feat(transport): live-call wiring (attestation handshake and A2A transport) Compose the landed decision core (peer.handle_peer_request), Cedar policy, sealed channel, and provenance DAG into a runnable live call, closing the two v0.2 "Remaining" items in ROADMAP: wire the decision core to a live A2A transport, and bind the seal to a verified attestation report on a live call. - attestation.py: handshake that gates the sealed channel on a verified channel key (offer, verify, seal). Software mode records assurance="none"; hardware plugs in via an injected verifier callable, so this module carries no hardware dependency. - transport/a2a.py: A2A wire binding. Parse an A2A message's ca2a block into a PeerRequest, build that message, and serialize result, error, and channel offer. - node.py: PeerNode holding the enclave channel key, policy, and provider. - transport/server.py, transport/client.py: standard-library-only reference HTTP server and client that run the full inbound pipeline over the wire. - tee/software.py: explicit, never-auto-selected software provider (no hardware guarantee), so the path runs off confidential hardware. - tests/unit/test_live_call.py: in-process pipeline, HTTP end-to-end, and fail-closed on over-scope, tampered payload, and stale nonce. Software mode runs off confidential hardware; the real-hardware quote path is the verifier seam. ruff, mypy --strict, and the full unit suite (136 tests) pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 6 +- src/ca2a_runtime/attestation.py | 121 ++++++++++++++++++++ src/ca2a_runtime/node.py | 54 +++++++++ src/ca2a_runtime/tee/software.py | 38 +++++++ src/ca2a_runtime/transport/__init__.py | 11 ++ src/ca2a_runtime/transport/a2a.py | 152 +++++++++++++++++++++++++ src/ca2a_runtime/transport/client.py | 91 +++++++++++++++ src/ca2a_runtime/transport/server.py | 97 ++++++++++++++++ tests/unit/test_live_call.py | 130 +++++++++++++++++++++ 9 files changed, 697 insertions(+), 3 deletions(-) create mode 100644 src/ca2a_runtime/attestation.py create mode 100644 src/ca2a_runtime/node.py create mode 100644 src/ca2a_runtime/tee/software.py create mode 100644 src/ca2a_runtime/transport/__init__.py create mode 100644 src/ca2a_runtime/transport/a2a.py create mode 100644 src/ca2a_runtime/transport/client.py create mode 100644 src/ca2a_runtime/transport/server.py create mode 100644 tests/unit/test_live_call.py diff --git a/ROADMAP.md b/ROADMAP.md index af79966..52226f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,8 +22,8 @@ Already implemented and tested elsewhere; cA2A depends on it rather than reimple ## v0.2: Runtime enforcement and sealed channel -- Runtime peer-delegation enforcement: **decision core landed** (`ca2a_runtime.peer.enforce_peer_call`: verify chain, intersect delegated scope with local policy, enforce, emit provenance record; claim C3 validated), now with a **real Cedar policy engine** option (`ca2a_runtime.cedar.CedarPolicy`) alongside the allow-set `LocalPolicy`. Remaining: wire the decision core to a live A2A transport (Tier 2) -- Sealed peer channel: **landed** (`ca2a_runtime.channel`: HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 sealing to the peer's attested key; claim C4 validated). Remaining: bind the seal to a verified attestation report on a live call, and rely on the enclave to hold the private key (hardware property) +- Runtime peer-delegation enforcement: **decision core landed** (`ca2a_runtime.peer.enforce_peer_call`: verify chain, intersect delegated scope with local policy, enforce, emit provenance record; claim C3 validated), now with a **real Cedar policy engine** option (`ca2a_runtime.cedar.CedarPolicy`) alongside the allow-set `LocalPolicy`. Live transport **landed** (`ca2a_runtime.transport`: A2A wire binding in `transport.a2a`, a reference standard-library HTTP server and client, and `ca2a_runtime.node.PeerNode`), exercised end to end in software mode by `tests/unit/test_live_call.py` +- Sealed peer channel: **landed** (`ca2a_runtime.channel`: HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 sealing to the peer's attested key; claim C4 validated). The seal is now **gated on a verified channel key** by the attestation handshake (`ca2a_runtime.attestation`: offer, verify, seal), so a payload is sealed only to an attested peer key; software mode records `assurance="none"` and hardware plugs in via a `verifier` callable. Remaining hardware property: the enclave holding the private key, established on a confidential VM - Linked runtime evidence: each hop's TRACE record references the parent record hash and delegation credential id, producing a verifiable delegation DAG (Tier 2) ## Critical path, sequenced first (Tier 3) @@ -34,7 +34,7 @@ Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via - **TDX verifier: landed.** DCAP Quote v4 parsing, PCK chain to the genuine Intel SGX Root CA, QE report signature, attestation-key binding, quote signature, and MRTD binding, all fail-closed. Quote generation requires a real TDX guest. See `ca2a_verify.tdx`. - **TPM 2.0 verifier: landed.** TPMS_ATTEST parsing, AK chain to a caller-supplied vendor root, AK signature (ECDSA or RSA), magic/type checks, and qualifying-data/PCR-digest binding, all fail-closed. Quote generation requires a real TPM. See `ca2a_verify.tpm`. - **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. -- **Pending:** end-to-end validation of the SEV-SNP, TDX, and TPM signature paths against real hardware quotes on a confidential VM; and a transport that parses real A2A wire messages into a `PeerRequest`. +- **Pending:** end-to-end validation of the SEV-SNP, TDX, and TPM signature paths against real hardware quotes on a confidential VM. 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/src/ca2a_runtime/attestation.py b/src/ca2a_runtime/attestation.py new file mode 100644 index 0000000..bc0c00c --- /dev/null +++ b/src/ca2a_runtime/attestation.py @@ -0,0 +1,121 @@ +"""Attestation handshake that gates the sealed channel on a live call. + +The sealed channel (:mod:`ca2a_runtime.channel`) seals a task payload to a peer's +X25519 channel key. For that to mean anything, the caller must first obtain that +key from a *verified* attestation report, so the payload is sealed only to a key +the peer's measured enclave holds. This module is that handshake: + +- the callee calls :func:`offer_channel` (or :func:`attest_channel` for a + long-lived enclave key) to bind its channel public key into an attestation + report under the caller's nonce; +- 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. + +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 +``verifier`` callable checks the hardware quote (wrapping :mod:`ca2a_verify`) and +the report-data binding, and :func:`verify_offer` returns ``assurance="hardware"``. +The ``verifier`` seam keeps this module free of any hardware dependency; driving +it off a real hardware quote end to end is the remaining hardware step (ROADMAP). +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from ca2a_runtime.channel import SealedChannel, generate_channel_keypair +from ca2a_runtime.errors import AttestationFailed +from ca2a_runtime.tee.base import AttestationReport, BaseProvider + +SOFTWARE_ONLY = "software-only" + +# A hardware verifier takes an attestation report and the nonce the caller +# expects, and returns the verified enclave measurement or raises +# AttestationFailed. On a confidential VM this wraps ca2a_verify (SEV-SNP, TDX, +# or TPM); it is injected so this module carries no hardware dependency. +Verifier = Callable[[AttestationReport, str], str] + + +@dataclass(frozen=True) +class ChannelOffer: + """A callee's attested channel-key offer: a public key and the report binding it.""" + + channel_public_key: str + report: AttestationReport + + +@dataclass(frozen=True) +class VerifiedPeer: + """A peer channel key the caller has appraised, with its assurance level.""" + + public_key: str + assurance: str # "hardware" or "none" + measurement: str + + +def attest_channel(provider: BaseProvider, public_key: str, nonce: str) -> ChannelOffer: + """Bind an existing enclave channel ``public_key`` into a report under ``nonce``.""" + return ChannelOffer( + channel_public_key=public_key, + report=provider.attest(public_key, nonce), + ) + + +def offer_channel( + provider: BaseProvider, *, nonce: str +) -> tuple[X25519PrivateKey, ChannelOffer]: + """Generate a channel keypair and bind its public key into a report under ``nonce``. + + The private key is returned to the callee and, on hardware, never leaves the + enclave. The offer carries the public key and the report that binds it. + """ + private_key, public_key = generate_channel_keypair() + return private_key, attest_channel(provider, public_key, nonce) + + +def verify_offer( + offer: ChannelOffer, *, expected_nonce: str, verifier: Verifier | None = None +) -> VerifiedPeer: + """Appraise a channel offer and return the peer key with its assurance level. + + Checks the report binds the offered public key under ``expected_nonce`` so a + stale or swapped offer is rejected. In ``software-only`` mode the assurance + is ``none``. If the report claims a hardware platform, a ``verifier`` is + required and establishes ``assurance="hardware"``; a hardware report with no + verifier fails closed rather than being trusted. Fails closed on any + mismatch (raises :class:`AttestationFailed`). + """ + report = offer.report + if report.public_key != offer.channel_public_key: + raise AttestationFailed("attestation report does not bind the offered channel key") + if report.nonce != expected_nonce: + raise AttestationFailed( + "attestation report nonce does not match the expected nonce", + detail="stale or replayed channel offer", + ) + if report.platform == SOFTWARE_ONLY: + return VerifiedPeer( + public_key=offer.channel_public_key, + assurance="none", + measurement=report.measurement, + ) + if verifier is None: + raise AttestationFailed( + f"a {report.platform!r} report requires a hardware verifier", + detail="refusing to trust a hardware attestation report without verification", + ) + measurement = verifier(report, expected_nonce) + return VerifiedPeer( + public_key=offer.channel_public_key, + assurance="hardware", + measurement=measurement, + ) + + +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/node.py b/src/ca2a_runtime/node.py new file mode 100644 index 0000000..0aaf830 --- /dev/null +++ b/src/ca2a_runtime/node.py @@ -0,0 +1,54 @@ +"""A cA2A peer node: the runtime state behind an inbound call. + +A :class:`PeerNode` holds the enclave channel keypair (the private key the sealed +channel opens against), the local policy the delegated scope is intersected with, +and the attestation provider. :meth:`PeerNode.offer` produces an attested +channel-key offer for the handshake; :meth:`PeerNode.handle` runs the full +inbound pipeline for a parsed cA2A-profile A2A message (verify chain, intersect +with policy, enforce, open the sealed payload with the enclave key, emit a linked +provenance record). The node is transport-agnostic; +:mod:`ca2a_runtime.transport.server` wraps it over HTTP. +""" + +from __future__ import annotations + +from typing import Any + +from ca2a_runtime.attestation import ChannelOffer, attest_channel +from ca2a_runtime.channel import generate_channel_keypair +from ca2a_runtime.peer import 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.""" + + def __init__( + self, + policy: Policy, + *, + provider: BaseProvider | None = None, + max_depth: int = 8, + ) -> None: + self.policy = policy + self.provider: BaseProvider = provider if provider is not None else SoftwareProvider() + self.max_depth = max_depth + self._private_key, self.channel_public_key = generate_channel_keypair() + + 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 handle(self, message: dict[str, Any]) -> PeerResult: + """Run the full inbound pipeline for a parsed cA2A-profile A2A message.""" + from ca2a_runtime.transport import a2a + + request = a2a.parse_peer_request(message) + return handle_peer_request( + request, + policy=self.policy, + enclave_private_key=self._private_key, + max_depth=self.max_depth, + ) diff --git a/src/ca2a_runtime/tee/software.py b/src/ca2a_runtime/tee/software.py new file mode 100644 index 0000000..ce8e712 --- /dev/null +++ b/src/ca2a_runtime/tee/software.py @@ -0,0 +1,38 @@ +"""Software-only attestation provider: NO hardware guarantee. + +This provider produces an attestation report that binds a public key under a +nonce but carries no hardware signature. It exists so the peer path is runnable +off confidential-computing hardware (development, CI, and software-mode +deployments), which is what lets the live transport run on ordinary compute such +as a container platform. A key obtained through this provider is marked +``assurance="none"`` by :func:`ca2a_runtime.attestation.verify_offer`. + +It is never selected by auto-detection: :meth:`detect` returns ``False``, so a +no-guarantee posture is always a deliberate, explicit choice (config provider +``software-only``), never a silent fallback. +""" + +from __future__ import annotations + +from ca2a_runtime.tee.base import AttestationReport, BaseProvider + +SOFTWARE_MEASUREMENT = "software-only-no-hardware-guarantee" + + +class SoftwareProvider(BaseProvider): + """A no-hardware provider for development and software-mode deployments.""" + + platform = "software-only" + + @classmethod + def detect(cls) -> bool: + # Never auto-selected: a no-guarantee posture must be chosen explicitly. + return False + + def attest(self, public_key: str, nonce: str) -> AttestationReport: + return AttestationReport( + platform=self.platform, + measurement=SOFTWARE_MEASUREMENT, + public_key=public_key, + nonce=nonce, + ) diff --git a/src/ca2a_runtime/transport/__init__.py b/src/ca2a_runtime/transport/__init__.py new file mode 100644 index 0000000..272b061 --- /dev/null +++ b/src/ca2a_runtime/transport/__init__.py @@ -0,0 +1,11 @@ +"""A2A transport binding for the cA2A peer path. + +cA2A is a profile on A2A, not a transport. This package provides the wire +binding (:mod:`ca2a_runtime.transport.a2a`) that parses a cA2A-profile A2A +message into a :class:`~ca2a_runtime.peer.PeerRequest` and serializes the result +or a structured error, plus a minimal reference HTTP server and client +(:mod:`ca2a_runtime.transport.server`, :mod:`ca2a_runtime.transport.client`) +that run the full inbound pipeline over the wire. The server is a reference, not +the only transport: any A2A server can parse its wire format and call the peer +path through :mod:`ca2a_runtime.transport.a2a`. +""" diff --git a/src/ca2a_runtime/transport/a2a.py b/src/ca2a_runtime/transport/a2a.py new file mode 100644 index 0000000..c5ac1c2 --- /dev/null +++ b/src/ca2a_runtime/transport/a2a.py @@ -0,0 +1,152 @@ +"""A2A wire binding: parse cA2A-profile A2A messages into runtime objects. + +A2A carries the task; cA2A adds a ``ca2a`` block to the message (the delegation +chain, the requested capability, a provenance record id and parent link, and an +optional sealed payload) plus a channel-offer exchange for the attestation +handshake. This module is the boundary the ROADMAP calls out: it parses a real +A2A message's ``ca2a`` block into a :class:`~ca2a_runtime.peer.PeerRequest`, +builds that message on the caller side, and serializes a +:class:`~ca2a_runtime.peer.PeerResult`, a :class:`~ca2a_runtime.attestation.ChannelOffer`, +or an error back onto the wire. It does no I/O; a transport does the I/O and +calls this. +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from ca2a_runtime.attestation import ChannelOffer +from ca2a_runtime.delegation.credential import DelegationCredential +from ca2a_runtime.errors import CA2AError, InvalidCredential +from ca2a_runtime.peer import PeerRequest, PeerResult +from ca2a_runtime.provenance import DelegationRecord +from ca2a_runtime.tee.base import AttestationReport + + +def _b64e(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def _b64d(value: str, field: str) -> bytes: + try: + return base64.b64decode(value, validate=True) + except (ValueError, TypeError) as exc: + raise InvalidCredential(f"invalid base64 in {field}", detail=str(exc)) from exc + + +def _credential_to_dict(cred: DelegationCredential) -> dict[str, Any]: + return {**cred.body(), "signature": cred.signature} + + +def _record_to_dict(record: DelegationRecord) -> dict[str, Any]: + body = record.body() + body["record_hash"] = record.record_hash() + return body + + +def build_task_message( + chain: list[DelegationCredential], + requested_capability: str, + record_id: str, + *, + sealed_payload: bytes | None = None, + parent_record_hash: str | None = None, +) -> dict[str, Any]: + """Build the cA2A-profile A2A message a caller sends for a delegated task. + + A real A2A message wraps this ``ca2a`` block inside its own params envelope; + the block is the profile's contribution and the unit this module parses. + """ + block: dict[str, Any] = { + "delegation_chain": [_credential_to_dict(c) for c in chain], + "requested_capability": requested_capability, + "record_id": record_id, + } + if sealed_payload is not None: + block["sealed_payload"] = _b64e(sealed_payload) + if parent_record_hash is not None: + block["parent_record_hash"] = parent_record_hash + return {"ca2a": block} + + +def parse_peer_request(message: dict[str, Any]) -> PeerRequest: + """Parse an A2A message's ``ca2a`` block into a PeerRequest. Fails closed.""" + block = message.get("ca2a") + if not isinstance(block, dict): + raise InvalidCredential("message has no ca2a block") + try: + raw_chain = block["delegation_chain"] + capability = str(block["requested_capability"]) + record_id = str(block["record_id"]) + except (KeyError, TypeError) as exc: + raise InvalidCredential("ca2a block missing a required field", detail=str(exc)) from exc + if not isinstance(raw_chain, list): + raise InvalidCredential("delegation_chain must be a list") + chain = [DelegationCredential.from_dict(item) for item in raw_chain] + + sealed = block.get("sealed_payload") + parent = block.get("parent_record_hash") + return PeerRequest( + chain=chain, + requested_capability=capability, + record_id=record_id, + sealed_payload=_b64d(sealed, "sealed_payload") if isinstance(sealed, str) else None, + parent_record_hash=str(parent) if parent is not None else None, + ) + + +def serialize_peer_result(result: PeerResult) -> dict[str, Any]: + """Serialize an accepted result onto the wire. + + The opened payload is the callee's confidential task input and is never + echoed back; the response confirms acceptance and returns the provenance + record (with its hash) so the caller can chain the next hop. + """ + return { + "accepted": True, + "effective_scope": sorted(result.effective_scope), + "granted_capability": result.granted_capability, + "record": _record_to_dict(result.record), + } + + +def serialize_error(err: CA2AError) -> dict[str, Any]: + """Serialize a CA2AError onto the wire using its stable code and status.""" + return { + "error": { + "code": err.code, + "message": str(err), + "detail": err.detail, + "http_status": err.http_status, + } + } + + +def serialize_channel_offer(offer: ChannelOffer) -> dict[str, Any]: + """Serialize a channel offer (the callee's attested channel key) onto the wire.""" + return { + "channel_public_key": offer.channel_public_key, + "attestation": { + "platform": offer.report.platform, + "measurement": offer.report.measurement, + "public_key": offer.report.public_key, + "nonce": offer.report.nonce, + }, + } + + +def parse_channel_offer(data: dict[str, Any]) -> ChannelOffer: + """Parse a channel offer received from a peer. Fails closed.""" + try: + public_key = str(data["channel_public_key"]) + att = data["attestation"] + report = AttestationReport( + platform=str(att["platform"]), + measurement=str(att["measurement"]), + public_key=str(att["public_key"]), + nonce=str(att["nonce"]), + ) + except (KeyError, TypeError) as exc: + raise InvalidCredential("malformed channel offer", detail=str(exc)) from exc + return ChannelOffer(channel_public_key=public_key, report=report) diff --git a/src/ca2a_runtime/transport/client.py b/src/ca2a_runtime/transport/client.py new file mode 100644 index 0000000..7f36af6 --- /dev/null +++ b/src/ca2a_runtime/transport/client.py @@ -0,0 +1,91 @@ +"""Reference HTTP client for the cA2A peer path (standard library only). + +The caller side of the live call: fetch a peer's attested channel key, verify it +under a fresh nonce, seal a payload to it, and send a delegated task. Uses urllib +only. On a confidential VM, pass a ``verifier`` that wraps :mod:`ca2a_verify`; +without one, the peer key is accepted at ``assurance="none"`` (software mode). +""" + +from __future__ import annotations + +import json +import secrets +import urllib.error +import urllib.request +from typing import Any + +from ca2a_runtime.attestation import VerifiedPeer, Verifier, seal_to_peer, verify_offer +from ca2a_runtime.delegation.credential import DelegationCredential +from ca2a_runtime.errors import CA2AError +from ca2a_runtime.transport import a2a +from ca2a_runtime.transport.server import CHANNEL_PATH, TASK_PATH + +_TIMEOUT = 10.0 + + +def _get_json(url: str) -> dict[str, Any]: + with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def _post_json(url: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + url, data=data, headers={"Content-Type": "application/json"}, method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: # noqa: S310 + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +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 = a2a.parse_channel_offer(_get_json(f"{base_url}{CHANNEL_PATH}?nonce={nonce}")) + return verify_offer(offer, expected_nonce=nonce, verifier=verifier) + + +def send_task( + base_url: str, + chain: list[DelegationCredential], + requested_capability: str, + record_id: str, + *, + payload: bytes | None = None, + verifier: Verifier | None = None, + parent_record_hash: str | None = None, +) -> dict[str, Any]: + """Run the caller side end to end: verify the peer, seal the payload, send the task. + + 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) + message = a2a.build_task_message( + chain, + requested_capability, + record_id, + sealed_payload=sealed, + parent_record_hash=parent_record_hash, + ) + status, body = _post_json(f"{base_url}{TASK_PATH}", message) + if status != 200: + err = body.get("error", {}) + raise _rehydrate_error(err) + return body + + +def _rehydrate_error(err: dict[str, Any]) -> CA2AError: + exc = CA2AError(str(err.get("message", "peer error")), detail=err.get("detail")) + code = err.get("code") + if isinstance(code, str): + exc.code = code + status = err.get("http_status") + if isinstance(status, int): + exc.http_status = status + return exc diff --git a/src/ca2a_runtime/transport/server.py b/src/ca2a_runtime/transport/server.py new file mode 100644 index 0000000..fb02d53 --- /dev/null +++ b/src/ca2a_runtime/transport/server.py @@ -0,0 +1,97 @@ +"""Reference HTTP transport for the cA2A peer path (standard library only). + +A minimal A2A server that runs the full inbound pipeline over the wire: + +- ``GET /.well-known/ca2a/channel?nonce=`` returns the callee's attested + channel key (the attestation handshake); +- ``POST /ca2a/task`` accepts a cA2A-profile A2A message, parses it into a + PeerRequest, runs verify + policy + enforce + open-sealed + provenance, and + returns the result, or a structured error at the error's own HTTP status. + +It is a reference, not the only transport: any A2A server can call +:mod:`ca2a_runtime.transport.a2a` and a :class:`~ca2a_runtime.node.PeerNode` the +same way. Standard library only, so it runs anywhere Python does, including a +plain container platform. +""" + +from __future__ import annotations + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, cast +from urllib.parse import parse_qs, urlparse + +from ca2a_runtime.errors import CA2AError +from ca2a_runtime.node import PeerNode +from ca2a_runtime.transport import a2a + +CHANNEL_PATH = "/.well-known/ca2a/channel" +TASK_PATH = "/ca2a/task" +_MAX_BODY = 1 << 20 # 1 MiB; fail closed on larger bodies + + +class PeerHTTPServer(ThreadingHTTPServer): + """A threading HTTP server carrying the PeerNode its handler serves.""" + + def __init__(self, address: tuple[str, int], node: PeerNode) -> None: + super().__init__(address, _PeerHandler) + self.node = node + + +class _PeerHandler(BaseHTTPRequestHandler): + server_version = "ca2a-ref/0.1" + + def _node(self) -> PeerNode: + return cast(PeerHTTPServer, self.server).node + + def _send_json(self, status: int, body: dict[str, Any]) -> None: + data = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, format: str, *args: Any) -> None: + # Silence default stderr access logging; a real deployment wires logging. + return + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path != CHANNEL_PATH: + self._send_json(404, {"error": {"code": "NOT_FOUND", "message": "unknown path"}}) + return + nonces = parse_qs(parsed.query).get("nonce", []) + 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, a2a.serialize_channel_offer(offer)) + + def do_POST(self) -> None: + if urlparse(self.path).path != TASK_PATH: + self._send_json(404, {"error": {"code": "NOT_FOUND", "message": "unknown path"}}) + return + length = int(self.headers.get("Content-Length", "0") or "0") + if length <= 0 or length > _MAX_BODY: + self._send_json(400, {"error": {"code": "BAD_REQUEST", "message": "invalid body length"}}) + return + try: + message = json.loads(self.rfile.read(length)) + except json.JSONDecodeError: + self._send_json(400, {"error": {"code": "BAD_REQUEST", "message": "invalid JSON"}}) + return + if not isinstance(message, dict): + self._send_json(400, {"error": {"code": "BAD_REQUEST", "message": "object required"}}) + return + try: + result = self._node().handle(message) + except CA2AError as exc: + self._send_json(exc.http_status, a2a.serialize_error(exc)) + return + self._send_json(200, a2a.serialize_peer_result(result)) + + +def serve(node: PeerNode, host: str = "127.0.0.1", port: int = 8443) -> PeerHTTPServer: + """Create a server bound to (host, port) serving ``node``. Call serve_forever().""" + return PeerHTTPServer((host, port), node) diff --git a/tests/unit/test_live_call.py b/tests/unit/test_live_call.py new file mode 100644 index 0000000..4614c10 --- /dev/null +++ b/tests/unit/test_live_call.py @@ -0,0 +1,130 @@ +"""End-to-end tests for the live-call wiring: attestation handshake, A2A wire +binding, and the full inbound pipeline through a PeerNode (software mode).""" + +from __future__ import annotations + +import threading + +import pytest + +from ca2a_runtime.attestation import seal_to_peer, verify_offer +from ca2a_runtime.delegation.credential import DelegationCredential, new_keypair +from ca2a_runtime.errors import ( + AttestationFailed, + CA2AError, + ScopeNotPermitted, + SealedChannelError, +) +from ca2a_runtime.node import PeerNode +from ca2a_runtime.policy import LocalPolicy +from ca2a_runtime.transport import a2a, client, server + + +def _chain() -> list[DelegationCredential]: + root_priv, root_pub = new_keypair() + callee_pub = new_keypair()[1] + cred = DelegationCredential( + credential_id="c0", + issuer=root_pub, + subject=callee_pub, + scope=frozenset({"read", "write"}), + depth=0, + ).sign(root_priv) + return [cred] + + +def test_live_inbound_flow_software_mode() -> None: + chain = _chain() + node = PeerNode(LocalPolicy.of({"read"})) + + nonce = "nonce-abc" + offer = node.offer(nonce) + peer = verify_offer(offer, expected_nonce=nonce) + assert peer.assurance == "none" + assert peer.public_key == node.channel_public_key + + sealed = seal_to_peer(peer, b"confidential task input") + message = a2a.build_task_message(chain, "read", "r0", sealed_payload=sealed) + result = node.handle(message) + + assert result.payload == b"confidential task input" + assert result.granted_capability == "read" + assert result.effective_scope == frozenset({"read"}) + assert result.record.credential_id == "c0" + assert result.record.parent_record_hash is None + + +def test_over_scope_capability_is_denied() -> None: + chain = _chain() + node = PeerNode(LocalPolicy.of({"read"})) # policy does not allow "write" + message = a2a.build_task_message(chain, "write", "r1") + with pytest.raises(ScopeNotPermitted): + node.handle(message) + + +def test_tampered_sealed_payload_fails_closed() -> None: + chain = _chain() + node = PeerNode(LocalPolicy.of({"read"})) + nonce = "nonce-xyz" + peer = verify_offer(node.offer(nonce), expected_nonce=nonce) + sealed = bytearray(seal_to_peer(peer, b"payload")) + sealed[-1] ^= 0x01 + message = a2a.build_task_message(chain, "read", "r2", sealed_payload=bytes(sealed)) + with pytest.raises(SealedChannelError): + node.handle(message) + + +def test_stale_offer_nonce_is_rejected() -> None: + node = PeerNode(LocalPolicy.of({"read"})) + offer = node.offer("nonce-1") + with pytest.raises(AttestationFailed): + verify_offer(offer, expected_nonce="a-different-nonce") + + +def test_wire_roundtrip_parses_chain() -> None: + chain = _chain() + message = a2a.build_task_message(chain, "read", "r0") + request = a2a.parse_peer_request(message) + assert request.requested_capability == "read" + assert len(request.chain) == 1 + assert request.chain[0].credential_id == "c0" + assert request.sealed_payload is None + + +def test_serialize_result_and_error_shapes() -> None: + chain = _chain() + node = PeerNode(LocalPolicy.of({"read"})) + result = node.handle(a2a.build_task_message(chain, "read", "r0")) + wire = a2a.serialize_peer_result(result) + assert wire["accepted"] is True + assert wire["granted_capability"] == "read" + assert wire["record"]["credential_id"] == "c0" + assert "record_hash" in wire["record"] + + err = ScopeNotPermitted("nope", detail="d") + err_wire = a2a.serialize_error(err) + assert err_wire["error"]["code"] == "SCOPE_NOT_PERMITTED" + assert err_wire["error"]["http_status"] == 403 + + +def test_http_live_call_end_to_end() -> None: + node = PeerNode(LocalPolicy.of({"read"})) + srv = server.serve(node, host="127.0.0.1", port=0) + port = srv.server_address[1] + thread = threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + try: + base = f"http://127.0.0.1:{port}" + chain = _chain() + + body = client.send_task(base, chain, "read", "r0", payload=b"hello over the wire") + assert body["accepted"] is True + assert body["granted_capability"] == "read" + assert body["record"]["credential_id"] == "c0" + + with pytest.raises(CA2AError) as exc_info: + client.send_task(base, chain, "write", "r1") + assert exc_info.value.code == "SCOPE_NOT_PERMITTED" + finally: + srv.shutdown() + srv.server_close() From fcb1476146de01f2189bbb11c7bcba6b92e46efe Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Mon, 20 Jul 2026 09:39:13 -0700 Subject: [PATCH 2/3] docs: reconcile scope with the shipped reference transport #48 added the A2A profile adapter; this branch adds a reference HTTP server/client, the attestation handshake, and PeerNode. The scope docs still said the A2A transport was flatly out of scope and that nothing served a live inbound call, which is now false in software mode. Reconcile honestly, keeping the transport in core (a reference, not the profile) and preserving the software-vs-hardware boundary: - LIMITATIONS: reference transport + handshake are built (software mode); the remaining gap is the hardware verifier seam, not live serving. Reframe the out-of-scope bullet: the profile mandates no transport, a non-normative reference one ships. - transport.md: status table and the overlay claim distinguish the profile (no new endpoint) from the reference transport (which does expose endpoints). - call-graph.md, concepts.md: the live path now runs in software mode; the hardware quote via the verifier seam is what remains. - CHANGELOG: record the reference transport under Unreleased. No code change: the transport already layers cleanly on #48's adapter after the merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 ++++++++++++++-- LIMITATIONS.md | 7 ++++--- docs/concepts.md | 2 ++ docs/spec/call-graph.md | 12 ++++++------ docs/spec/transport.md | 12 +++++++----- 5 files changed, 33 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a18d17a..c6e0901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 metadata on A2A `SendMessage`-shaped messages into `PeerRequest` (and the reverse). Extension URI `https://agentrust.io/extensions/ca2a/v0.1`. Fail closed on malformed cA2A metadata; absence of all cA2A keys returns `None` (ordinary - A2A). Does not add HTTP serving, `ca2a start`, or seal-to-verified-measurement - binding. New error `TRANSPORT_ERROR`. See issue #47. + A2A). The adapter itself adds no HTTP serving or seal-to-verified-measurement + binding; the reference transport below adds serving, and hardware measurement + binding is still pending. New error `TRANSPORT_ERROR`. See issue #47. +- Reference HTTP transport and attestation handshake, software mode (Tier 2): + `ca2a_runtime.transport.server`/`client` (standard library only) run a live + inbound A2A-profile call end to end over HTTP, `ca2a_runtime.node.PeerNode` + composes the provider, policy, adapter, and `handle_peer_request`, and + `ca2a_runtime.attestation` (offer/verify/seal) gates the seal on a channel key + the caller appraises under a fresh nonce. `ca2a_runtime.tee.software.SoftwareProvider` + supplies the no-hardware provider (never auto-selected). This is a **reference** + transport, not part of the profile: the profile mandates no wire protocol. In + software mode the peer key is `assurance="none"`; binding the seal to a + hardware-verified measurement (the `verifier` seam wrapping `ca2a_verify`) is + the remaining hardware step. Exercised end to end by `tests/unit/test_live_call.py`. ## [0.1.0a1] - 2026-07-09 diff --git a/LIMITATIONS.md b/LIMITATIONS.md index b4ecc08..342d892 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -6,16 +6,17 @@ cA2A is a pre-release profile in active design. This document states plainly wha - The delegation credential model and the offline chain verifier skeleton: signature checks, scope attenuation (a child grant must be a provable subset of its parent), depth limits, and cross-chain replay rejection. The hardest of these semantics is reused from [agent-manifest](https://github.com/agentrust-io/agent-manifest), where it is implemented and tested. - Configuration, error registry, and the CLI surface. +- A reference HTTP transport and the attestation handshake, in software mode. `ca2a_runtime.transport.server` and `ca2a_runtime.transport.client` (standard library only) run a live inbound A2A-profile call end to end: the caller fetches the callee's attested channel key, seals a payload to it, and sends a delegated task; the callee parses the A2A metadata with the adapter, runs verify + policy + enforce + open-sealed + provenance, and replies. `ca2a_runtime.attestation` gates the seal on a verified channel key. This is a **reference** transport, not part of the profile: the profile mandates no wire protocol (see Out of scope), and in software mode the peer key is accepted at `assurance="none"`. ## What is stubbed or not yet implemented -- **Live A2A transport serving.** An A2A metadata adapter (`ca2a_runtime.transport`) can parse/attach cA2A extension fields into a `PeerRequest`, and `handle_peer_request` enforces that request in-process. There is still no HTTP/JSON-RPC listener or `ca2a start`: nothing accepts a live inbound A2A socket call end to end. Parsing extension metadata is Tier 2 transport progress, not a claim that cA2A is attested across trust domains. -- **Sealed peer channel (live binding).** 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. The remaining gap is the hardware property that the private key never leaves the peer's enclave (established by attestation), and wiring the seal to a verified report on a live inbound call. Until that end-to-end binding lands on hardware, do not assume a payload is confined to a specific attested measurement. Adapter-decoded `sealed_payload` bytes are opaque ciphertext only. +- **Hardware-attested live binding.** The live transport and handshake run today in **software mode only** (the reference server/client above, `assurance="none"`). The remaining gap is hardware: driving the `verifier` seam in `ca2a_runtime.attestation` off a real SEV-SNP, TDX, or TPM quote (wrapping `ca2a_verify`) so the peer's channel key is bound to a hardware-verified enclave measurement, and relying on the enclave to hold the channel private key. There is also no CLI listener (`ca2a start`); serving is via `ca2a_runtime.transport.server.serve`. A software-mode live call is Tier 2 progress, not a claim that cA2A is attested across trust domains. +- **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 verifier is implemented**: report parsing, VCEK certificate chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. The chain path is validated against the genuine AMD Milan root chain; the report-signature path is validated with synthetic vectors, since a real report plus VCEK pair needs SEV-SNP hardware. Report generation (`SevSnpProvider.attest`) still requires a real SEV-SNP guest. The **Intel TDX verifier** (DCAP Quote v4: PCK chain to the genuine Intel SGX Root CA, QE report, attestation-key binding, quote signature, MRTD binding) and the **TPM 2.0 verifier** (AK chain to a caller-supplied vendor root, AK signature, magic/type, qualifying-data and PCR-digest binding) are also implemented and synthetic-vector validated; quote generation needs the respective hardware. Until a backend verifies a real quote end to end against a golden measurement on hardware, cA2A must not be described as fully attested across trust domains. ## Out of scope -- The A2A transport itself. cA2A is a profile on A2A, not a replacement for it. +- A normative or production A2A transport. cA2A is a profile on A2A, not a replacement for it, and mandates no wire protocol. A reference HTTP transport ships (`ca2a_runtime.transport.server`/`client`) so the peer path is runnable on ordinary compute, but it is a convenience, not part of the profile: any A2A server can drive the adapter and a `PeerNode` instead, and cA2A makes no claim about the reference transport's production hardening. - Agent identity issuance beyond delegation. - AI model governance beyond delegation and provenance. - Hardware TEE platform SDKs and firmware. diff --git a/docs/concepts.md b/docs/concepts.md index c398faa..9fd0685 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -38,3 +38,5 @@ Agent A --(delegation cred, scope S_A)--> Agent B --(scope S_B ⊆ S_A)--> Agent ## Profile, not protocol cA2A binds to A2A the way TRACE binds to IETF RATS, EAT, and SCITT: it is an overlay, not a competitor. This keeps it neutral across org, cloud, and TEE-vendor boundaries, which is the claim a vendor-anchored verifier cannot make. + +The profile mandates no wire protocol. A reference HTTP transport ships (`ca2a_runtime.transport.server`/`client`) so the peer path is runnable off hardware, but it is a convenience, not part of the profile: any A2A server can carry the extension fields instead. diff --git a/docs/spec/call-graph.md b/docs/spec/call-graph.md index 084cb68..73b9e8a 100644 --- a/docs/spec/call-graph.md +++ b/docs/spec/call-graph.md @@ -2,7 +2,7 @@ When a cA2A peer receives an inbound A2A task, it runs a fixed sequence of checks before it acts on the task and after it acts. The order is not arbitrary: cheap, offline, deterministic checks run first, and each step fails closed so a later step never runs against unverified input. This page states the full intended enforcement order and marks, for each step, what the code does today versus what is design. -Steps 1 (chain verification), 3 (scope intersection), 4 (opening a sealed payload), and 5 (provenance emission) are composed into one transport-agnostic handler, `ca2a_runtime.peer.handle_peer_request`, which takes a parsed `PeerRequest` and runs the pipeline fail-closed. Step 2 has a SEV-SNP verifier (`ca2a_verify.sev_snp`), used counterparty-side to seal to a peer before sending (see the cross-operator flow), but is not part of the callee handler and needs real hardware to produce a report. What remains for a live deployment is a transport that parses actual A2A wire messages into a `PeerRequest`; cA2A leaves that to implementers by design (profile, not protocol). See [LIMITATIONS.md](../../LIMITATIONS.md) and [ROADMAP.md](../../ROADMAP.md). +Steps 1 (chain verification), 3 (scope intersection), 4 (opening a sealed payload), and 5 (provenance emission) are composed into one transport-agnostic handler, `ca2a_runtime.peer.handle_peer_request`, which takes a parsed `PeerRequest` and runs the pipeline fail-closed. The `ca2a_runtime.transport` adapter parses actual A2A extension metadata into that `PeerRequest`, and a reference HTTP server/client (`ca2a_runtime.transport.server`/`client`) now drive the whole path live in software mode. Step 2 is what remains for a cross-trust-domain deployment: a SEV-SNP verifier exists (`ca2a_verify.sev_snp`, used counterparty-side to seal to a peer before sending), but producing a report needs real hardware, and the `verifier` seam in `ca2a_runtime.attestation` is not yet driven off a live hardware quote. The profile itself still mandates no wire protocol (the reference transport is a convenience, not part of it). See [LIMITATIONS.md](../../LIMITATIONS.md) and [ROADMAP.md](../../ROADMAP.md). ## Decision flow @@ -40,7 +40,7 @@ If any step raises, the call is denied. Absence of evidence is denial, not a war | 4. Payload sealing | Payload sealed to the peer's attested key | `SealedChannel.seal`, `open_sealed` | Implemented (crypto); binding to a verified report on the live path pending | | 5. Provenance record | A `DelegationRecord` emitted and linked to its parent | `enforce_peer_call`, `record_for`, `verify_dag` | Implemented (emitted by the decision core) | | Inbound pipeline handler | Verify, enforce, open sealed payload, emit record off a parsed request | `handle_peer_request`, `PeerRequest` | Implemented (transport-agnostic) | -| A2A wire parsing into a `PeerRequest` | Parse actual A2A extension fields into the handler's input | (implementer/transport) | Left to implementers by design | +| A2A wire parsing into a `PeerRequest` | Parse actual A2A extension fields into the handler's input | `ca2a_runtime.transport.a2a_adapter`, and the reference `transport.server`/`client` and `PeerNode` | Implemented (software mode); the profile still mandates no specific transport | ## Step 1: verify the delegation chain (implemented) @@ -74,11 +74,11 @@ effective_scope(chain, policy) # delegated leaf scope AND enforce_peer_call(chain, "read", policy=policy, record_id="rec-c") # raises SCOPE_NOT_PERMITTED if not in the effective scope ``` -A capability is granted only when it is both delegated down the chain and allowed by the local policy; a capability in one but not the other is denied with `SCOPE_NOT_PERMITTED`. The `LocalPolicy` here is a capability allow set; binding a full Cedar policy engine (as cMCP does) is tracked separately (#10). What is not yet wired is the live inbound transport: `enforce_peer_call` is the decision the runtime makes, not yet driven off an actual A2A request. See [cedar-policy.md](cedar-policy.md). +A capability is granted only when it is both delegated down the chain and allowed by the local policy; a capability in one but not the other is denied with `SCOPE_NOT_PERMITTED`. The `LocalPolicy` here is a capability allow set; a full Cedar policy engine (as cMCP uses) is also available via `ca2a_runtime.cedar.CedarPolicy`. `enforce_peer_call` is the decision the runtime makes; the reference `PeerNode` and HTTP server now drive it off an actual A2A request in software mode. See [cedar-policy.md](cedar-policy.md). ## Step 4: seal the payload to the peer's attested key (crypto implemented) -Once the peer's attested public key is known (from its report, step 2), the task payload is sealed to it so only the holder of the peer's private key can open it. The channel is implemented (`SealedChannel(peer_pub).seal(...)` and `open_sealed(...)`, an HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 scheme). `open_sealed` fails closed with `SEALED_CHANNEL_ERROR` on a wrong key or tampered ciphertext. The property that the payload decrypts *only inside the attested measurement* rests on the private key being enclave-bound (a hardware property from attestation), and binding the seal to a verified report on the live path is still to be wired. See [sealed-channel.md](sealed-channel.md). +Once the peer's attested public key is known (from its report, step 2), the task payload is sealed to it so only the holder of the peer's private key can open it. The channel is implemented (`SealedChannel(peer_pub).seal(...)` and `open_sealed(...)`, an HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305 scheme). `open_sealed` fails closed with `SEALED_CHANNEL_ERROR` on a wrong key or tampered ciphertext. On a live call the handshake (`ca2a_runtime.attestation.verify_offer`) now gates the seal on a channel key the caller has appraised under a fresh nonce, but in software mode that appraisal is `assurance="none"`. The property that the payload decrypts *only inside the attested measurement* rests on the private key being enclave-bound and on a hardware-verified measurement, which is the remaining hardware step. See [sealed-channel.md](sealed-channel.md). ## Step 5: emit a linked provenance record (implemented) @@ -95,7 +95,7 @@ verify_dag(records) # raises PROVENANCE_LINK_BROKEN on tampering cross_check_chain(records, chain) # record i must match credential i (id + subject) ``` -`enforce_peer_call` produces this record for the accepted hop, linked to the parent by hash. `verify_dag` raises `PROVENANCE_LINK_BROKEN` if a record was tampered with or reparented, because the stored `parent_record_hash` no longer matches the recomputed hash of the preceding record, and `cross_check_chain` ties provenance to authority. What remains is driving emission off a live inbound A2A request rather than a direct `enforce_peer_call`. The record format and DAG semantics are the runtime-evidence side of the profile; see [trace-a2a-profile.md](trace-a2a-profile.md) and [provenance-dag.md](provenance-dag.md). +`enforce_peer_call` produces this record for the accepted hop, linked to the parent by hash. `verify_dag` raises `PROVENANCE_LINK_BROKEN` if a record was tampered with or reparented, because the stored `parent_record_hash` no longer matches the recomputed hash of the preceding record, and `cross_check_chain` ties provenance to authority. The reference `PeerNode`/HTTP server now drive this emission off a live inbound A2A request in software mode. The record format and DAG semantics are the runtime-evidence side of the profile; see [trace-a2a-profile.md](trace-a2a-profile.md) and [provenance-dag.md](provenance-dag.md). ## Why this order @@ -106,4 +106,4 @@ Each step is a precondition for the next, so ordering is a safety property: - Scope intersection (3) precedes acting on the task because the effective grant, not the raw delegated scope, is what the peer is allowed to exercise. - Provenance emission (5) runs after the task is accepted so the record reflects what was actually done, and it links to the parent record so the workflow forms a verifiable DAG. -This release enforces bounded authority and local-policy intersection (steps 1 and 3) and emits and verifies linked provenance records (step 5) through the `enforce_peer_call` decision core. It does not yet enforce peer integrity or payload confidentiality (steps 2 and 4), and the decision core is not yet wired to a live A2A transport. See the residual-risks section of the [threat-model.md](threat-model.md). +This release enforces bounded authority and local-policy intersection (steps 1 and 3) and emits and verifies linked provenance records (step 5) through the `enforce_peer_call` decision core, and the reference transport drives the whole path off a live A2A request in software mode. What it does not yet do is enforce hardware peer integrity or hardware-measured payload confidentiality (steps 2 and 4): those need a real attestation quote through the `verifier` seam. See the residual-risks section of the [threat-model.md](threat-model.md). diff --git a/docs/spec/transport.md b/docs/spec/transport.md index e9b3f76..0d29317 100644 --- a/docs/spec/transport.md +++ b/docs/spec/transport.md @@ -9,11 +9,13 @@ cA2A is a profile on A2A, not a competing transport. A2A moves tasks and context | Extension URI + namespaced metadata keys (below) | Specified | | `ca2a_runtime.transport` adapter: A2A metadata ↔ `PeerRequest` | Implemented (parse/attach only) | | Hand-off into `handle_peer_request` once a `PeerRequest` exists | Implemented in-process; callers invoke it after parsing | -| HTTP/JSON-RPC serving / `ca2a start` | Not yet — separate Tier 2 checkbox | -| Live attestation handshake on an inbound call | Not yet — separate Tier 2/3 checkbox | -| Seal bound to a **verified** attestation measurement on a live call | Not yet — separate Tier 2/3 checkbox | +| 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"`) | +| 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 | Not yet — serving is via `transport.server.serve` | -Parsing real A2A extension metadata is progress on Tier 2 transport wiring. It is **not** evidence that cA2A is attested across trust domains. See [LIMITATIONS.md](../../LIMITATIONS.md) and [ROADMAP.md](../../ROADMAP.md). +The reference HTTP server/client run a live call end to end **in software mode** (`assurance="none"`). That is progress on Tier 2 transport wiring and a convenience for running the peer path off hardware. It is **not** evidence that cA2A is attested across trust domains: that needs the hardware `verifier` seam driven by a real quote. See [LIMITATIONS.md](../../LIMITATIONS.md) and [ROADMAP.md](../../ROADMAP.md). ## Overlay, not fork @@ -22,7 +24,7 @@ cA2A does not define its own transport, message framing, or handshake. It rides - The **delegation credential** (or the chain root-to-leaf), naming issuer, subject, scope, depth, and parent link. See [the delegation chain](delegation-chain.md). - The **sealing metadata**, carrying an opaque sealed ciphertext when the caller seals the task payload. Sealing crypto is implemented; binding that seal to a verified peer measurement on a live call is still Tier 2/3. See [the sealed channel](sealed-channel.md). -Both ride in A2A extension fields. cA2A claims no new wire format and no new endpoint. Removing every cA2A field leaves a valid A2A task. +Both ride in A2A extension fields. The **profile** claims no new wire format and no new endpoint: removing every cA2A field leaves a valid A2A task. The reference HTTP transport (`ca2a_runtime.transport.server`) does expose convenience endpoints, including an attestation-handshake path for fetching the callee's channel key, but those belong to the reference transport, not the profile. They are one way to carry the extension fields, not a requirement of the profile, and any A2A server can carry them instead. ## Extension URI and metadata keys From c71b365b6154e180db43c75845c514b073e5766c Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Mon, 20 Jul 2026 11:13:41 -0700 Subject: [PATCH 3/3] fix(transport): reject non-HTTP(S) URLs in the reference client (bandit B310) bandit flagged B310 on the reference client's two urllib.request.urlopen calls (file:/ and custom schemes). The existing `# noqa: S310` only silenced ruff, not bandit, so `bandit -r src/` failed CI. Validate the URL scheme against an http/https allowlist before opening, failing closed with TransportError, and annotate the calls for both tools. Add a test for the rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ca2a_runtime/transport/client.py | 24 +++++++++++++++++++++--- tests/unit/test_live_call.py | 14 +++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/ca2a_runtime/transport/client.py b/src/ca2a_runtime/transport/client.py index ae1843b..83d8c25 100644 --- a/src/ca2a_runtime/transport/client.py +++ b/src/ca2a_runtime/transport/client.py @@ -11,31 +11,49 @@ import json import secrets import urllib.error +import urllib.parse import urllib.request from typing import Any from ca2a_runtime.attestation import VerifiedPeer, Verifier, seal_to_peer, verify_offer from ca2a_runtime.delegation.credential import DelegationCredential -from ca2a_runtime.errors import CA2AError +from ca2a_runtime.errors import CA2AError, TransportError from ca2a_runtime.peer import PeerRequest from ca2a_runtime.transport import a2a_adapter, wire from ca2a_runtime.transport.server import CHANNEL_PATH, TASK_PATH _TIMEOUT = 10.0 +_ALLOWED_SCHEMES = ("http", "https") + + +def _require_http_url(url: str) -> None: + """Reject any non-HTTP(S) URL before opening it, failing closed. + + ``urllib.request.urlopen`` will open ``file:`` and custom schemes too, which + is what bandit B310 / ruff S310 warn about. The reference client only ever + talks HTTP(S) to a peer base URL, so any other scheme is a misconfiguration. + """ + if urllib.parse.urlparse(url).scheme not in _ALLOWED_SCHEMES: + raise TransportError( + "refusing to open a non-HTTP(S) URL", + detail=f"scheme must be one of {_ALLOWED_SCHEMES}", + ) def _get_json(url: str) -> dict[str, Any]: - with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: # noqa: S310 + _require_http_url(url) + with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: # noqa: S310 # nosec B310 return json.loads(resp.read()) def _post_json(url: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + _require_http_url(url) data = json.dumps(body).encode("utf-8") req = urllib.request.Request( url, data=data, headers={"Content-Type": "application/json"}, method="POST" ) try: - with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: # noqa: S310 + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: # noqa: S310 # nosec B310 return resp.status, json.loads(resp.read()) except urllib.error.HTTPError as exc: return exc.code, json.loads(exc.read()) diff --git a/tests/unit/test_live_call.py b/tests/unit/test_live_call.py index 618d2dd..462aaec 100644 --- a/tests/unit/test_live_call.py +++ b/tests/unit/test_live_call.py @@ -11,13 +11,25 @@ from ca2a_runtime.attestation import seal_to_peer, verify_offer from ca2a_runtime.delegation.credential import DelegationCredential, new_keypair -from ca2a_runtime.errors import AttestationFailed, CA2AError, ScopeNotPermitted, SealedChannelError +from ca2a_runtime.errors import ( + AttestationFailed, + CA2AError, + ScopeNotPermitted, + SealedChannelError, + TransportError, +) from ca2a_runtime.node import PeerNode from ca2a_runtime.peer import PeerRequest from ca2a_runtime.policy import LocalPolicy from ca2a_runtime.transport import a2a_adapter, client, server, wire +@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"): + client._get_json(url) + + def _chain() -> list[DelegationCredential]: root_priv, root_pub = new_keypair() callee_pub = new_keypair()[1]