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.
- ca2a_runtime/__init__.py +3 -0
- ca2a_runtime/benchmarks.py +69 -0
- ca2a_runtime/canonical.py +69 -0
- ca2a_runtime/cedar.py +46 -0
- ca2a_runtime/channel/__init__.py +14 -0
- ca2a_runtime/channel/sealed.py +97 -0
- ca2a_runtime/cli.py +144 -0
- ca2a_runtime/config.py +73 -0
- ca2a_runtime/delegation/__init__.py +20 -0
- ca2a_runtime/delegation/credential.py +176 -0
- ca2a_runtime/errors.py +83 -0
- ca2a_runtime/peer.py +142 -0
- ca2a_runtime/policy.py +48 -0
- ca2a_runtime/provenance.py +115 -0
- ca2a_runtime/tee/__init__.py +22 -0
- ca2a_runtime/tee/base.py +36 -0
- ca2a_runtime/tee/sev_snp.py +105 -0
- ca2a_runtime/tee/tdx.py +133 -0
- ca2a_runtime/tee/tpm.py +98 -0
- ca2a_runtime-0.1.0a1.dist-info/METADATA +178 -0
- ca2a_runtime-0.1.0a1.dist-info/RECORD +30 -0
- ca2a_runtime-0.1.0a1.dist-info/WHEEL +4 -0
- ca2a_runtime-0.1.0a1.dist-info/entry_points.txt +2 -0
- ca2a_runtime-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- ca2a_runtime-0.1.0a1.dist-info/licenses/NOTICE +24 -0
- ca2a_verify/__init__.py +20 -0
- ca2a_verify/sev_snp.py +103 -0
- ca2a_verify/tdx.py +96 -0
- ca2a_verify/tpm.py +72 -0
- ca2a_verify/verify.py +64 -0
ca2a_runtime/__init__.py
ADDED
|
@@ -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
|
+
]
|