ca2a-runtime 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """cA2A Runtime: confidential agent-to-agent delegation on top of A2A."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,69 @@
1
+ """Delegation-chain verification microbenchmark.
2
+
3
+ Software-only path: measures per-hop verify latency so CI can gate regressions.
4
+ Real attestation and sealed-channel costs are added once Tier 2/3 land.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from ca2a_runtime.delegation import DelegationCredential, new_keypair, verify_chain
15
+
16
+
17
+ def _build_chain(hops: int) -> list[DelegationCredential]:
18
+ scope = frozenset(f"cap:{i}" for i in range(16))
19
+ priv, pub = new_keypair()
20
+ chain: list[DelegationCredential] = []
21
+ parent_id: str | None = None
22
+ for depth in range(hops):
23
+ next_priv, next_pub = new_keypair()
24
+ cred = DelegationCredential(
25
+ credential_id=f"cred-{depth}",
26
+ issuer=pub,
27
+ subject=next_pub,
28
+ scope=scope,
29
+ depth=depth,
30
+ parent_id=parent_id,
31
+ ).sign(priv)
32
+ chain.append(cred)
33
+ parent_id = cred.credential_id
34
+ priv, pub = next_priv, next_pub
35
+ return chain
36
+
37
+
38
+ def run(hops: int, out: Path | None) -> dict[str, float]:
39
+ latencies: list[float] = []
40
+ for _ in range(hops):
41
+ chain = _build_chain(2)
42
+ start = time.perf_counter()
43
+ verify_chain(chain, max_depth=64)
44
+ latencies.append((time.perf_counter() - start) * 1000.0)
45
+ latencies.sort()
46
+ p99 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.99))]
47
+ result = {"hops": float(hops), "p99_ms": p99, "mean_ms": sum(latencies) / len(latencies)}
48
+ if out is not None:
49
+ out.mkdir(parents=True, exist_ok=True)
50
+ (out / "delegation-verify.json").write_text(json.dumps(result, indent=2))
51
+ return result
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ parser = argparse.ArgumentParser(prog="ca2a-benchmarks")
56
+ parser.add_argument("--provider", default="software-only")
57
+ parser.add_argument("--hops", type=int, default=10000)
58
+ parser.add_argument("--out", type=Path, default=None)
59
+ args = parser.parse_args(argv)
60
+ result = run(args.hops, args.out)
61
+ print(json.dumps(result))
62
+ if result["p99_ms"] >= 5.0:
63
+ print("p99 gate exceeded (>= 5ms)")
64
+ return 1
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ raise SystemExit(main())
@@ -0,0 +1,69 @@
1
+ """RFC 8785 JSON Canonicalization Scheme (JCS) for the value types cA2A signs.
2
+
3
+ Credentials and provenance records are signed over the canonical byte encoding
4
+ of a JSON object. RFC 8785 fixes that encoding so any conforming implementation
5
+ (here and in agent-manifest) produces identical bytes and therefore
6
+ cross-verifiable signatures.
7
+
8
+ This implements JCS for the JSON value types cA2A uses: objects, arrays,
9
+ strings, integers, booleans, and null. Object keys are sorted by their UTF-16
10
+ code units, strings use JCS minimal escaping (control characters only; non-ASCII
11
+ is emitted literally as UTF-8), and integers serialize as their shortest decimal
12
+ form. Floating-point numbers are not part of the cA2A data model and are
13
+ rejected rather than serialized approximately.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ # JCS short escapes for control characters (RFC 8785 section 3.2.2.2).
21
+ _SHORT_ESCAPES = {
22
+ 0x08: "\\b",
23
+ 0x09: "\\t",
24
+ 0x0A: "\\n",
25
+ 0x0C: "\\f",
26
+ 0x0D: "\\r",
27
+ 0x22: '\\"',
28
+ 0x5C: "\\\\",
29
+ }
30
+
31
+
32
+ def _escape_string(s: str) -> str:
33
+ out: list[str] = ['"']
34
+ for ch in s:
35
+ code = ord(ch)
36
+ if code in _SHORT_ESCAPES:
37
+ out.append(_SHORT_ESCAPES[code])
38
+ elif code < 0x20:
39
+ out.append(f"\\u{code:04x}")
40
+ else:
41
+ out.append(ch)
42
+ out.append('"')
43
+ return "".join(out)
44
+
45
+
46
+ def _serialize(value: Any) -> str:
47
+ if value is None:
48
+ return "null"
49
+ if value is True:
50
+ return "true"
51
+ if value is False:
52
+ return "false"
53
+ if isinstance(value, str):
54
+ return _escape_string(value)
55
+ if isinstance(value, int): # bool already handled above
56
+ return str(value)
57
+ if isinstance(value, float):
58
+ raise TypeError("RFC 8785 canonicalization of floats is not supported in cA2A")
59
+ if isinstance(value, list):
60
+ return "[" + ",".join(_serialize(v) for v in value) + "]"
61
+ if isinstance(value, dict):
62
+ items = sorted(value.items(), key=lambda kv: str(kv[0]).encode("utf-16-be"))
63
+ return "{" + ",".join(f"{_escape_string(str(k))}:{_serialize(v)}" for k, v in items) + "}"
64
+ raise TypeError(f"unsupported type for canonicalization: {type(value).__name__}")
65
+
66
+
67
+ def canonicalize(value: Any) -> bytes:
68
+ """Return the RFC 8785 canonical UTF-8 encoding of ``value``."""
69
+ return _serialize(value).encode("utf-8")
ca2a_runtime/cedar.py ADDED
@@ -0,0 +1,46 @@
1
+ """A local policy backed by a real Cedar policy engine.
2
+
3
+ `CedarPolicy` evaluates the callee's Cedar policy to decide which capabilities a
4
+ peer may exercise. It satisfies the `ca2a_runtime.policy.Policy` protocol, so it
5
+ is a drop-in for `LocalPolicy` in the peer path: the effective scope on an
6
+ inbound call is the delegated leaf scope intersected with what Cedar permits.
7
+
8
+ Each capability is evaluated as a Cedar authorization request whose action id is
9
+ the capability name; a capability is permitted iff Cedar returns Allow. This
10
+ reuses the same policy engine cMCP runs (see docs/spec/cedar-policy.md).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from cedarpy import Decision, is_authorized
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class CedarPolicy:
23
+ """A local policy backed by a Cedar policy set."""
24
+
25
+ policies: str
26
+ principal_type: str = "Agent"
27
+ principal_id: str = "peer"
28
+ resource_type: str = "Task"
29
+ resource_id: str = "task"
30
+
31
+ def _request(self, capability: str) -> dict[str, Any]:
32
+ return {
33
+ "principal": {"type": self.principal_type, "id": self.principal_id},
34
+ "action": {"type": "Action", "id": capability},
35
+ "resource": {"type": self.resource_type, "id": self.resource_id},
36
+ "context": {},
37
+ }
38
+
39
+ def permits(self, capability: str) -> bool:
40
+ """Return True iff Cedar authorizes an action of this capability's name."""
41
+ result = is_authorized(self._request(capability), self.policies, [])
42
+ return bool(result.decision == Decision.Allow)
43
+
44
+ def intersect(self, delegated: frozenset[str]) -> frozenset[str]:
45
+ """Return the effective scope: delegated capabilities Cedar permits."""
46
+ return frozenset(cap for cap in delegated if self.permits(cap))
@@ -0,0 +1,14 @@
1
+ """Sealed peer channel: seal a task payload to a peer's attested key.
2
+
3
+ HPKE-style X25519 -> HKDF-SHA256 -> ChaCha20-Poly1305. Only the holder of the
4
+ private key bound to the peer's attested measurement can open a sealed payload.
5
+ See docs/spec/sealed-channel.md.
6
+ """
7
+
8
+ from ca2a_runtime.channel.sealed import (
9
+ SealedChannel,
10
+ generate_channel_keypair,
11
+ open_sealed,
12
+ )
13
+
14
+ __all__ = ["SealedChannel", "generate_channel_keypair", "open_sealed"]
@@ -0,0 +1,97 @@
1
+ """Sealed peer channel: seal a task payload to a peer's attested key.
2
+
3
+ The channel binds a payload to the X25519 public key that a peer's attestation
4
+ report vouches for (see docs/spec/attestation.md). The scheme is HPKE-style:
5
+ an ephemeral X25519 ECDH to the peer key, HKDF-SHA256 to derive a symmetric
6
+ key, and ChaCha20-Poly1305 AEAD over the payload. Only the holder of the
7
+ private key can open the result.
8
+
9
+ Confidentiality of the payload rests on that private key. On real hardware the
10
+ key is generated and held inside the peer's enclave and never leaves it, so the
11
+ payload decrypts only inside the attested measurement. This module implements
12
+ the cryptography; the guarantee that the private key is enclave-bound is what
13
+ attestation establishes, and driving the seal off a verified report on a live
14
+ call is tracked as runtime wiring (see ROADMAP.md).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from cryptography.exceptions import InvalidTag
22
+ from cryptography.hazmat.primitives.asymmetric.x25519 import (
23
+ X25519PrivateKey,
24
+ X25519PublicKey,
25
+ )
26
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
27
+ from cryptography.hazmat.primitives.hashes import SHA256
28
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
29
+
30
+ from ca2a_runtime.errors import SealedChannelError
31
+
32
+ _VERSION = 1
33
+ _HKDF_INFO = b"ca2a/sealed-channel/v1"
34
+ _EPH_LEN = 32
35
+ _NONCE_LEN = 12
36
+ _HEADER_LEN = 1 + _EPH_LEN + _NONCE_LEN
37
+
38
+
39
+ def generate_channel_keypair() -> tuple[X25519PrivateKey, str]:
40
+ """Return an enclave channel keypair and its public key as raw hex.
41
+
42
+ On hardware this runs inside the peer's enclave and the public key is bound
43
+ into the attestation report; the private key never leaves the enclave.
44
+ """
45
+ priv = X25519PrivateKey.generate()
46
+ pub_hex = priv.public_key().public_bytes_raw().hex()
47
+ return priv, pub_hex
48
+
49
+
50
+ def _derive_key(shared: bytes, eph_pub: bytes, peer_pub: bytes) -> bytes:
51
+ return HKDF(algorithm=SHA256(), length=32, salt=None, info=_HKDF_INFO).derive(
52
+ eph_pub + peer_pub + shared
53
+ )
54
+
55
+
56
+ class SealedChannel:
57
+ """Sender side: seals payloads to a peer's attested X25519 public key."""
58
+
59
+ def __init__(self, peer_public_key: str) -> None:
60
+ try:
61
+ self._peer_pub = X25519PublicKey.from_public_bytes(bytes.fromhex(peer_public_key))
62
+ except ValueError as exc:
63
+ raise SealedChannelError("invalid peer public key", detail=str(exc)) from exc
64
+ self.peer_public_key = peer_public_key
65
+
66
+ def seal(self, payload: bytes, *, aad: bytes = b"") -> bytes:
67
+ """Seal ``payload`` to the peer key. The result is opaque to anyone
68
+ without the peer's private key; ``aad`` is authenticated, not encrypted."""
69
+ eph_priv = X25519PrivateKey.generate()
70
+ eph_pub = eph_priv.public_key().public_bytes_raw()
71
+ peer_pub_raw = self._peer_pub.public_bytes_raw()
72
+ shared = eph_priv.exchange(self._peer_pub)
73
+ key = _derive_key(shared, eph_pub, peer_pub_raw)
74
+ nonce = os.urandom(_NONCE_LEN)
75
+ ct = ChaCha20Poly1305(key).encrypt(nonce, payload, aad)
76
+ return bytes([_VERSION]) + eph_pub + nonce + ct
77
+
78
+
79
+ def open_sealed(blob: bytes, private_key: X25519PrivateKey, *, aad: bytes = b"") -> bytes:
80
+ """Open a sealed payload with the enclave-bound private key.
81
+
82
+ Fails closed with SealedChannelError on a malformed blob, a wrong key, or a
83
+ tampered ciphertext (AEAD authentication failure); it never returns
84
+ unauthenticated plaintext.
85
+ """
86
+ if len(blob) < _HEADER_LEN or blob[0] != _VERSION:
87
+ raise SealedChannelError("malformed or unsupported sealed payload")
88
+ eph_pub = blob[1 : 1 + _EPH_LEN]
89
+ nonce = blob[1 + _EPH_LEN : _HEADER_LEN]
90
+ ct = blob[_HEADER_LEN:]
91
+ try:
92
+ peer_pub_raw = private_key.public_key().public_bytes_raw()
93
+ shared = private_key.exchange(X25519PublicKey.from_public_bytes(eph_pub))
94
+ key = _derive_key(shared, eph_pub, peer_pub_raw)
95
+ return ChaCha20Poly1305(key).decrypt(nonce, ct, aad)
96
+ except (InvalidTag, ValueError) as exc:
97
+ raise SealedChannelError("sealed payload failed to open", detail=str(exc)) from exc
ca2a_runtime/cli.py ADDED
@@ -0,0 +1,144 @@
1
+ """ca2a command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ca2a_runtime import __version__
12
+ from ca2a_runtime.config import Ca2aConfig
13
+ from ca2a_runtime.delegation import DelegationCredential, verify_chain
14
+ from ca2a_runtime.errors import CA2AError, InvalidCredential, ProvenanceLinkBroken
15
+ from ca2a_runtime.provenance import DelegationRecord, cross_check_chain, verify_dag
16
+ from ca2a_verify import verify_chain_file
17
+
18
+
19
+ def _cmd_validate_config(args: argparse.Namespace) -> int:
20
+ try:
21
+ cfg = Ca2aConfig.load(args.config)
22
+ except CA2AError as exc:
23
+ print(f"invalid config: {exc}", file=sys.stderr)
24
+ return 1
25
+ print(f"ok: provider={cfg.provider} enforcement={cfg.enforcement_mode}")
26
+ return 0
27
+
28
+
29
+ def _cmd_verify_chain(args: argparse.Namespace) -> int:
30
+ try:
31
+ result = verify_chain_file(Path(args.chain), max_depth=args.max_depth)
32
+ except CA2AError as exc:
33
+ print(json.dumps({"verified": False, "code": exc.code, "error": str(exc)}))
34
+ return 1
35
+ print(json.dumps({"verified": True, "hops": result.hops, "leaf_scope": result.leaf_scope}))
36
+ return 0
37
+
38
+
39
+ def _load_chain(path: str) -> list[DelegationCredential]:
40
+ p = Path(path)
41
+ if not p.is_file():
42
+ raise InvalidCredential(f"chain file not found: {p}")
43
+ try:
44
+ data: Any = json.loads(p.read_text(encoding="utf-8"))
45
+ except json.JSONDecodeError as exc:
46
+ raise InvalidCredential(f"invalid JSON in {p}", detail=str(exc)) from exc
47
+ if isinstance(data, dict) and "chain" in data:
48
+ data = data["chain"]
49
+ if not isinstance(data, list):
50
+ raise InvalidCredential('chain document must be a list or {"chain": [...]}')
51
+ return [DelegationCredential.from_dict(item) for item in data]
52
+
53
+
54
+ def _load_records(path: str) -> list[DelegationRecord]:
55
+ p = Path(path)
56
+ if not p.is_file():
57
+ raise ProvenanceLinkBroken(f"dag file not found: {p}")
58
+ try:
59
+ data: Any = json.loads(p.read_text(encoding="utf-8"))
60
+ except json.JSONDecodeError as exc:
61
+ raise ProvenanceLinkBroken(f"invalid JSON in {p}", detail=str(exc)) from exc
62
+ if isinstance(data, dict) and "records" in data:
63
+ data = data["records"]
64
+ if not isinstance(data, list):
65
+ raise ProvenanceLinkBroken('dag document must be a list or {"records": [...]}')
66
+ records: list[DelegationRecord] = []
67
+ for item in data:
68
+ try:
69
+ records.append(
70
+ DelegationRecord(
71
+ record_id=str(item["record_id"]),
72
+ credential_id=str(item["credential_id"]),
73
+ subject=str(item["subject"]),
74
+ scope=frozenset(str(s) for s in item["scope"]),
75
+ parent_record_hash=(
76
+ None
77
+ if item.get("parent_record_hash") is None
78
+ else str(item["parent_record_hash"])
79
+ ),
80
+ )
81
+ )
82
+ except (KeyError, TypeError, ValueError) as exc:
83
+ raise ProvenanceLinkBroken("malformed provenance record", detail=str(exc)) from exc
84
+ return records
85
+
86
+
87
+ def _cmd_verify_dag(args: argparse.Namespace) -> int:
88
+ try:
89
+ records = verify_dag(_load_records(args.dag))
90
+ cross_checked = False
91
+ if args.chain:
92
+ chain = _load_chain(args.chain)
93
+ verify_chain(chain, max_depth=args.max_depth)
94
+ cross_check_chain(records, chain)
95
+ cross_checked = True
96
+ except CA2AError as exc:
97
+ print(json.dumps({"verified": False, "code": exc.code, "error": str(exc)}))
98
+ return 1
99
+ out = {
100
+ "verified": True,
101
+ "records": len(records),
102
+ "leaf_scope": sorted(records[-1].scope),
103
+ }
104
+ if args.chain:
105
+ out["cross_checked"] = cross_checked
106
+ print(json.dumps(out))
107
+ return 0
108
+
109
+
110
+ def build_parser() -> argparse.ArgumentParser:
111
+ parser = argparse.ArgumentParser(prog="ca2a", description="Confidential agent-to-agent")
112
+ parser.add_argument("--version", action="version", version=f"ca2a {__version__}")
113
+ sub = parser.add_subparsers(dest="command", required=True)
114
+
115
+ vc = sub.add_parser("validate-config", help="Validate a ca2a config file")
116
+ vc.add_argument("--config", required=True)
117
+ vc.set_defaults(func=_cmd_validate_config)
118
+
119
+ vch = sub.add_parser("verify-chain", help="Verify a delegation chain offline")
120
+ vch.add_argument("--chain", required=True)
121
+ vch.add_argument("--max-depth", type=int, default=8)
122
+ vch.set_defaults(func=_cmd_verify_chain)
123
+
124
+ vd = sub.add_parser("verify-dag", help="Verify a provenance DAG offline")
125
+ vd.add_argument("--dag", required=True)
126
+ vd.add_argument(
127
+ "--chain",
128
+ help="Optional delegation chain to cross-check the DAG against",
129
+ )
130
+ vd.add_argument("--max-depth", type=int, default=8)
131
+ vd.set_defaults(func=_cmd_verify_dag)
132
+
133
+ return parser
134
+
135
+
136
+ def main(argv: list[str] | None = None) -> int:
137
+ parser = build_parser()
138
+ args = parser.parse_args(argv)
139
+ result: int = args.func(args)
140
+ return result
141
+
142
+
143
+ if __name__ == "__main__":
144
+ raise SystemExit(main())
ca2a_runtime/config.py ADDED
@@ -0,0 +1,73 @@
1
+ """Runtime configuration for the cA2A peer runtime.
2
+
3
+ The runtime peer path is under construction (see ROADMAP.md); this module
4
+ defines and validates the configuration surface it will consume.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from ca2a_runtime.errors import ConfigError
16
+
17
+ VALID_PROVIDERS = frozenset(
18
+ {"auto", "tpm", "sev-snp", "tdx", "opaque", "software-only"}
19
+ )
20
+ VALID_ENFORCEMENT = frozenset({"enforcing", "advisory", "silent"})
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Ca2aConfig:
25
+ """Validated cA2A runtime configuration."""
26
+
27
+ provider: str = "auto"
28
+ enforcement_mode: str = "enforcing"
29
+ max_delegation_depth: int = 8
30
+ policy_bundle_path: str | None = None
31
+ listen_addr: str = "0.0.0.0:8443"
32
+
33
+ @classmethod
34
+ def from_dict(cls, data: dict[str, Any]) -> Ca2aConfig:
35
+ attestation = data.get("attestation", {}) or {}
36
+ provider = attestation.get("provider", "auto")
37
+ enforcement = attestation.get("enforcement_mode", "enforcing")
38
+
39
+ if provider not in VALID_PROVIDERS:
40
+ raise ConfigError(
41
+ f"unknown attestation provider: {provider!r}",
42
+ detail=f"expected one of {sorted(VALID_PROVIDERS)}",
43
+ )
44
+ if enforcement not in VALID_ENFORCEMENT:
45
+ raise ConfigError(
46
+ f"unknown enforcement_mode: {enforcement!r}",
47
+ detail=f"expected one of {sorted(VALID_ENFORCEMENT)}",
48
+ )
49
+
50
+ depth = data.get("max_delegation_depth", 8)
51
+ if not isinstance(depth, int) or depth < 1:
52
+ raise ConfigError("max_delegation_depth must be a positive integer")
53
+
54
+ return cls(
55
+ provider=provider,
56
+ enforcement_mode=enforcement,
57
+ max_delegation_depth=depth,
58
+ policy_bundle_path=data.get("policy_bundle_path"),
59
+ listen_addr=data.get("listen_addr", "0.0.0.0:8443"),
60
+ )
61
+
62
+ @classmethod
63
+ def load(cls, path: str | Path) -> Ca2aConfig:
64
+ p = Path(path)
65
+ if not p.is_file():
66
+ raise ConfigError(f"config file not found: {p}")
67
+ try:
68
+ data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
69
+ except yaml.YAMLError as exc:
70
+ raise ConfigError(f"invalid YAML in {p}", detail=str(exc)) from exc
71
+ if not isinstance(data, dict):
72
+ raise ConfigError(f"config root must be a mapping, got {type(data).__name__}")
73
+ return cls.from_dict(data)
@@ -0,0 +1,20 @@
1
+ """Attenuated delegation: signed credentials whose scope narrows at each hop.
2
+
3
+ The semantics here mirror the signed A2A delegation chain implemented and
4
+ tested in agent-manifest (https://github.com/agentrust-io/agent-manifest);
5
+ this package is the runtime-side model and verifier the cA2A peer path calls.
6
+ """
7
+
8
+ from ca2a_runtime.delegation.credential import (
9
+ DelegationCredential,
10
+ canonical_bytes,
11
+ new_keypair,
12
+ verify_chain,
13
+ )
14
+
15
+ __all__ = [
16
+ "DelegationCredential",
17
+ "canonical_bytes",
18
+ "new_keypair",
19
+ "verify_chain",
20
+ ]