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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Delegation credential model, canonicalization, and chain verification.
|
|
2
|
+
|
|
3
|
+
A delegation credential is a signed statement that ``issuer`` grants ``subject``
|
|
4
|
+
a set of capability strings (``scope``), optionally as a child of ``parent_id``.
|
|
5
|
+
A chain is a list of credentials ordered from root to leaf. Verification enforces
|
|
6
|
+
four invariants:
|
|
7
|
+
|
|
8
|
+
1. Signature: each credential verifies against its issuer's Ed25519 public key.
|
|
9
|
+
2. Continuity: each hop's issuer is the previous hop's subject.
|
|
10
|
+
3. Attenuation: each hop's scope is a subset of its parent's scope.
|
|
11
|
+
4. Anti-replay: parent_id links to the previous credential_id and every
|
|
12
|
+
credential_id in the chain is unique.
|
|
13
|
+
|
|
14
|
+
Canonicalization uses RFC 8785 (JSON Canonicalization Scheme), so the signed
|
|
15
|
+
byte string is identical across conforming implementations and cA2A signatures
|
|
16
|
+
are cross-verifiable with agent-manifest. See ca2a_runtime.canonical.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from cryptography.exceptions import InvalidSignature
|
|
25
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
26
|
+
Ed25519PrivateKey,
|
|
27
|
+
Ed25519PublicKey,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from ca2a_runtime.canonical import canonicalize
|
|
31
|
+
from ca2a_runtime.errors import (
|
|
32
|
+
BrokenDelegationLink,
|
|
33
|
+
CredentialReplay,
|
|
34
|
+
DelegationDepthExceeded,
|
|
35
|
+
InvalidCredential,
|
|
36
|
+
ScopeEscalation,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def new_keypair() -> tuple[Ed25519PrivateKey, str]:
|
|
41
|
+
"""Return a fresh Ed25519 private key and its public key as raw hex."""
|
|
42
|
+
priv = Ed25519PrivateKey.generate()
|
|
43
|
+
pub_hex = priv.public_key().public_bytes_raw().hex()
|
|
44
|
+
return priv, pub_hex
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def canonical_bytes(payload: dict[str, Any]) -> bytes:
|
|
48
|
+
"""RFC 8785 (JCS) canonical byte encoding of a credential body.
|
|
49
|
+
|
|
50
|
+
This is the stable byte string signed and verified; using JCS makes cA2A
|
|
51
|
+
signatures cross-verifiable with agent-manifest and any other conforming
|
|
52
|
+
implementation. See ca2a_runtime.canonical.
|
|
53
|
+
"""
|
|
54
|
+
return canonicalize(payload)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class DelegationCredential:
|
|
59
|
+
"""A single signed delegation hop."""
|
|
60
|
+
|
|
61
|
+
credential_id: str
|
|
62
|
+
issuer: str # Ed25519 public key, raw hex
|
|
63
|
+
subject: str # Ed25519 public key, raw hex
|
|
64
|
+
scope: frozenset[str]
|
|
65
|
+
depth: int
|
|
66
|
+
parent_id: str | None = None
|
|
67
|
+
signature: str = "" # Ed25519 signature over canonical_bytes(body), hex
|
|
68
|
+
|
|
69
|
+
def body(self) -> dict[str, Any]:
|
|
70
|
+
"""The signed portion of the credential (everything but the signature)."""
|
|
71
|
+
return {
|
|
72
|
+
"credential_id": self.credential_id,
|
|
73
|
+
"issuer": self.issuer,
|
|
74
|
+
"subject": self.subject,
|
|
75
|
+
"scope": sorted(self.scope),
|
|
76
|
+
"depth": self.depth,
|
|
77
|
+
"parent_id": self.parent_id,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def sign(self, private_key: Ed25519PrivateKey) -> DelegationCredential:
|
|
81
|
+
"""Return a copy signed by ``private_key`` (must match ``issuer``)."""
|
|
82
|
+
expected = private_key.public_key().public_bytes_raw().hex()
|
|
83
|
+
if expected != self.issuer:
|
|
84
|
+
raise InvalidCredential(
|
|
85
|
+
"signing key does not match credential issuer",
|
|
86
|
+
detail=f"issuer={self.issuer} key={expected}",
|
|
87
|
+
)
|
|
88
|
+
sig = private_key.sign(canonical_bytes(self.body())).hex()
|
|
89
|
+
return DelegationCredential(
|
|
90
|
+
credential_id=self.credential_id,
|
|
91
|
+
issuer=self.issuer,
|
|
92
|
+
subject=self.subject,
|
|
93
|
+
scope=self.scope,
|
|
94
|
+
depth=self.depth,
|
|
95
|
+
parent_id=self.parent_id,
|
|
96
|
+
signature=sig,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def verify_signature(self) -> None:
|
|
100
|
+
"""Raise InvalidCredential if the signature does not verify."""
|
|
101
|
+
if not self.signature:
|
|
102
|
+
raise InvalidCredential("credential is unsigned")
|
|
103
|
+
try:
|
|
104
|
+
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(self.issuer))
|
|
105
|
+
pub.verify(bytes.fromhex(self.signature), canonical_bytes(self.body()))
|
|
106
|
+
except (InvalidSignature, ValueError) as exc:
|
|
107
|
+
raise InvalidCredential(
|
|
108
|
+
"credential signature failed to verify", detail=str(exc)
|
|
109
|
+
) from exc
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: dict[str, Any]) -> DelegationCredential:
|
|
113
|
+
try:
|
|
114
|
+
return cls(
|
|
115
|
+
credential_id=str(data["credential_id"]),
|
|
116
|
+
issuer=str(data["issuer"]),
|
|
117
|
+
subject=str(data["subject"]),
|
|
118
|
+
scope=frozenset(str(s) for s in data["scope"]),
|
|
119
|
+
depth=int(data["depth"]),
|
|
120
|
+
parent_id=(None if data.get("parent_id") is None else str(data["parent_id"])),
|
|
121
|
+
signature=str(data.get("signature", "")),
|
|
122
|
+
)
|
|
123
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
124
|
+
raise InvalidCredential("malformed credential", detail=str(exc)) from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def verify_chain(
|
|
128
|
+
chain: list[DelegationCredential], *, max_depth: int = 8
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Verify a root-to-leaf delegation chain, raising on the first violation.
|
|
131
|
+
|
|
132
|
+
A well-formed chain of length N delegates from the root issuer down to the
|
|
133
|
+
leaf subject with monotonically narrowing scope. Raises the specific
|
|
134
|
+
CA2AError subtype for the invariant that failed.
|
|
135
|
+
"""
|
|
136
|
+
if not chain:
|
|
137
|
+
raise BrokenDelegationLink("empty delegation chain")
|
|
138
|
+
|
|
139
|
+
seen_ids: set[str] = set()
|
|
140
|
+
prev: DelegationCredential | None = None
|
|
141
|
+
|
|
142
|
+
for i, cred in enumerate(chain):
|
|
143
|
+
cred.verify_signature()
|
|
144
|
+
|
|
145
|
+
if cred.credential_id in seen_ids:
|
|
146
|
+
raise CredentialReplay(f"duplicate credential_id at hop {i}: {cred.credential_id}")
|
|
147
|
+
seen_ids.add(cred.credential_id)
|
|
148
|
+
|
|
149
|
+
if cred.depth > max_depth:
|
|
150
|
+
raise DelegationDepthExceeded(
|
|
151
|
+
f"hop {i} depth {cred.depth} exceeds max {max_depth}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if prev is None:
|
|
155
|
+
if cred.parent_id is not None:
|
|
156
|
+
raise BrokenDelegationLink("root credential must not name a parent")
|
|
157
|
+
if cred.depth != 0:
|
|
158
|
+
raise BrokenDelegationLink("root credential must have depth 0")
|
|
159
|
+
else:
|
|
160
|
+
if cred.parent_id != prev.credential_id:
|
|
161
|
+
raise BrokenDelegationLink(
|
|
162
|
+
f"hop {i} parent_id does not match previous credential_id"
|
|
163
|
+
)
|
|
164
|
+
if cred.issuer != prev.subject:
|
|
165
|
+
raise BrokenDelegationLink(
|
|
166
|
+
f"hop {i} issuer is not the previous hop's subject"
|
|
167
|
+
)
|
|
168
|
+
if cred.depth != prev.depth + 1:
|
|
169
|
+
raise BrokenDelegationLink(f"hop {i} depth is not previous + 1")
|
|
170
|
+
if not cred.scope.issubset(prev.scope):
|
|
171
|
+
escalated = sorted(cred.scope - prev.scope)
|
|
172
|
+
raise ScopeEscalation(
|
|
173
|
+
f"hop {i} scope exceeds parent grant", detail=f"added: {escalated}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
prev = cred
|
ca2a_runtime/errors.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Central error code registry - mirrors docs/spec/error-codes semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CA2AError(Exception):
|
|
7
|
+
"""Base class for all ca2a-runtime errors."""
|
|
8
|
+
|
|
9
|
+
code: str = "CA2A_ERROR"
|
|
10
|
+
http_status: int = 500
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, *, detail: str | None = None) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.detail = detail
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(CA2AError):
|
|
18
|
+
code = "CONFIG_ERROR"
|
|
19
|
+
http_status = 500
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidCredential(CA2AError):
|
|
23
|
+
"""A delegation credential is malformed or its signature does not verify."""
|
|
24
|
+
|
|
25
|
+
code = "INVALID_CREDENTIAL"
|
|
26
|
+
http_status = 400
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ScopeEscalation(CA2AError):
|
|
30
|
+
"""A child grant claims authority its parent did not hold."""
|
|
31
|
+
|
|
32
|
+
code = "SCOPE_ESCALATION"
|
|
33
|
+
http_status = 403
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BrokenDelegationLink(CA2AError):
|
|
37
|
+
"""A hop does not chain to its stated parent, or continuity is broken."""
|
|
38
|
+
|
|
39
|
+
code = "BROKEN_DELEGATION_LINK"
|
|
40
|
+
http_status = 409
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DelegationDepthExceeded(CA2AError):
|
|
44
|
+
code = "DELEGATION_DEPTH_EXCEEDED"
|
|
45
|
+
http_status = 403
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CredentialReplay(CA2AError):
|
|
49
|
+
"""A credential id appears more than once in a chain."""
|
|
50
|
+
|
|
51
|
+
code = "CREDENTIAL_REPLAY"
|
|
52
|
+
http_status = 409
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AttestationUnsupported(CA2AError):
|
|
56
|
+
code = "ATTESTATION_UNSUPPORTED"
|
|
57
|
+
http_status = 500
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AttestationFailed(CA2AError):
|
|
61
|
+
code = "ATTESTATION_FAILED"
|
|
62
|
+
http_status = 412
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SealedChannelError(CA2AError):
|
|
66
|
+
code = "SEALED_CHANNEL_ERROR"
|
|
67
|
+
http_status = 500
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ProvenanceLinkBroken(CA2AError):
|
|
71
|
+
"""A delegation record does not chain to its stated parent record, or a
|
|
72
|
+
record has been tampered with so its hash no longer matches a child's link."""
|
|
73
|
+
|
|
74
|
+
code = "PROVENANCE_LINK_BROKEN"
|
|
75
|
+
http_status = 409
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ScopeNotPermitted(CA2AError):
|
|
79
|
+
"""A requested capability is not in the effective scope (the delegated
|
|
80
|
+
scope intersected with the callee's local policy)."""
|
|
81
|
+
|
|
82
|
+
code = "SCOPE_NOT_PERMITTED"
|
|
83
|
+
http_status = 403
|
ca2a_runtime/peer.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Inbound peer-call enforcement: the decision the callee makes before it acts.
|
|
2
|
+
|
|
3
|
+
When a peer presents a delegation chain and requests a capability, the callee:
|
|
4
|
+
|
|
5
|
+
1. verifies the chain (signature, continuity, attenuation, depth, replay);
|
|
6
|
+
2. computes the effective scope as the leaf's delegated scope intersected with
|
|
7
|
+
the callee's local policy;
|
|
8
|
+
3. enforces: the requested capability must be in the effective scope;
|
|
9
|
+
4. emits a provenance record for the accepted hop, linked to its parent.
|
|
10
|
+
|
|
11
|
+
`enforce_peer_call` is the enforcement decision core. `handle_peer_request`
|
|
12
|
+
composes it into the full transport-agnostic inbound pipeline: verify, enforce,
|
|
13
|
+
open any sealed payload with the enclave key, and emit a provenance record. A
|
|
14
|
+
transport (an A2A server) parses its wire format into a `PeerRequest` and calls
|
|
15
|
+
this; cA2A does not define the transport itself, only what the peer does with a
|
|
16
|
+
parsed request.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
|
24
|
+
|
|
25
|
+
from ca2a_runtime.channel import open_sealed
|
|
26
|
+
from ca2a_runtime.delegation.credential import DelegationCredential, verify_chain
|
|
27
|
+
from ca2a_runtime.errors import ScopeNotPermitted, SealedChannelError
|
|
28
|
+
from ca2a_runtime.policy import Policy
|
|
29
|
+
from ca2a_runtime.provenance import DelegationRecord, record_for
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def effective_scope(
|
|
33
|
+
chain: list[DelegationCredential], policy: Policy, *, max_depth: int = 8
|
|
34
|
+
) -> frozenset[str]:
|
|
35
|
+
"""Verify the chain and return the effective scope (delegated ∩ local policy).
|
|
36
|
+
|
|
37
|
+
Raises the relevant CA2AError if the chain does not verify.
|
|
38
|
+
"""
|
|
39
|
+
verify_chain(chain, max_depth=max_depth)
|
|
40
|
+
return policy.intersect(chain[-1].scope)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class PeerDecision:
|
|
45
|
+
"""The result of an accepted peer call."""
|
|
46
|
+
|
|
47
|
+
effective_scope: frozenset[str]
|
|
48
|
+
granted_capability: str
|
|
49
|
+
record: DelegationRecord
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def enforce_peer_call(
|
|
53
|
+
chain: list[DelegationCredential],
|
|
54
|
+
requested_capability: str,
|
|
55
|
+
*,
|
|
56
|
+
policy: Policy,
|
|
57
|
+
record_id: str,
|
|
58
|
+
parent_record_hash: str | None = None,
|
|
59
|
+
max_depth: int = 8,
|
|
60
|
+
) -> PeerDecision:
|
|
61
|
+
"""Verify, intersect with local policy, enforce, and emit a provenance record.
|
|
62
|
+
|
|
63
|
+
Raises ScopeNotPermitted if the requested capability is not in the effective
|
|
64
|
+
scope, and the underlying CA2AError if the chain does not verify. On accept,
|
|
65
|
+
returns a PeerDecision carrying the linked provenance record.
|
|
66
|
+
"""
|
|
67
|
+
effective = effective_scope(chain, policy, max_depth=max_depth)
|
|
68
|
+
if requested_capability not in effective:
|
|
69
|
+
raise ScopeNotPermitted(
|
|
70
|
+
f"capability {requested_capability!r} is not in the effective scope",
|
|
71
|
+
detail=f"effective={sorted(effective)}",
|
|
72
|
+
)
|
|
73
|
+
record = record_for(chain[-1], record_id=record_id, parent_record_hash=parent_record_hash)
|
|
74
|
+
return PeerDecision(
|
|
75
|
+
effective_scope=effective,
|
|
76
|
+
granted_capability=requested_capability,
|
|
77
|
+
record=record,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class PeerRequest:
|
|
83
|
+
"""A transport-agnostic inbound peer request.
|
|
84
|
+
|
|
85
|
+
A transport (an A2A server) parses its wire format into this shape and hands
|
|
86
|
+
it to ``handle_peer_request``. cA2A does not define the transport; it defines
|
|
87
|
+
what a peer does with the request once parsed.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
chain: list[DelegationCredential]
|
|
91
|
+
requested_capability: str
|
|
92
|
+
record_id: str
|
|
93
|
+
sealed_payload: bytes | None = None
|
|
94
|
+
parent_record_hash: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class PeerResult:
|
|
99
|
+
"""The outcome of handling an accepted peer request."""
|
|
100
|
+
|
|
101
|
+
effective_scope: frozenset[str]
|
|
102
|
+
granted_capability: str
|
|
103
|
+
record: DelegationRecord
|
|
104
|
+
payload: bytes | None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def handle_peer_request(
|
|
108
|
+
request: PeerRequest,
|
|
109
|
+
*,
|
|
110
|
+
policy: Policy,
|
|
111
|
+
enclave_private_key: X25519PrivateKey | None = None,
|
|
112
|
+
max_depth: int = 8,
|
|
113
|
+
) -> PeerResult:
|
|
114
|
+
"""Run the full inbound pipeline for a parsed peer request.
|
|
115
|
+
|
|
116
|
+
Verifies the delegation chain, intersects the delegated scope with the local
|
|
117
|
+
policy and enforces the requested capability, opens any sealed payload with
|
|
118
|
+
the enclave-bound key, and emits a linked provenance record. Fails closed:
|
|
119
|
+
any verification or authorization failure raises the relevant CA2AError and
|
|
120
|
+
no payload is returned.
|
|
121
|
+
"""
|
|
122
|
+
decision = enforce_peer_call(
|
|
123
|
+
request.chain,
|
|
124
|
+
request.requested_capability,
|
|
125
|
+
policy=policy,
|
|
126
|
+
record_id=request.record_id,
|
|
127
|
+
parent_record_hash=request.parent_record_hash,
|
|
128
|
+
max_depth=max_depth,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
payload: bytes | None = None
|
|
132
|
+
if request.sealed_payload is not None:
|
|
133
|
+
if enclave_private_key is None:
|
|
134
|
+
raise SealedChannelError("a sealed payload was sent but no enclave key is available")
|
|
135
|
+
payload = open_sealed(request.sealed_payload, enclave_private_key)
|
|
136
|
+
|
|
137
|
+
return PeerResult(
|
|
138
|
+
effective_scope=decision.effective_scope,
|
|
139
|
+
granted_capability=decision.granted_capability,
|
|
140
|
+
record=decision.record,
|
|
141
|
+
payload=payload,
|
|
142
|
+
)
|
ca2a_runtime/policy.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Local peer policy for scope intersection.
|
|
2
|
+
|
|
3
|
+
A callee constrains what an inbound peer may do with a set of allowed
|
|
4
|
+
capabilities. The effective permission on a peer call is the delegated scope
|
|
5
|
+
intersected with this allow set, so a peer can never exercise more than both
|
|
6
|
+
its grant and the callee's local policy permit.
|
|
7
|
+
|
|
8
|
+
The intersection semantics here are policy-language-agnostic. Binding a full
|
|
9
|
+
Cedar policy engine (as cMCP does) is tracked separately; this allow-set model
|
|
10
|
+
is the enforcement primitive the peer path uses today.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class Policy(Protocol):
|
|
22
|
+
"""A local policy the peer path intersects a delegated scope against.
|
|
23
|
+
|
|
24
|
+
Implemented by ``LocalPolicy`` (a capability allow set) and by
|
|
25
|
+
``ca2a_runtime.cedar.CedarPolicy`` (a real Cedar policy engine).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def intersect(self, delegated: frozenset[str]) -> frozenset[str]:
|
|
29
|
+
"""Return the effective scope: the delegated capabilities this policy allows."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class LocalPolicy:
|
|
35
|
+
"""A callee's local capability allow set."""
|
|
36
|
+
|
|
37
|
+
allow: frozenset[str]
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def of(cls, caps: Iterable[str]) -> LocalPolicy:
|
|
41
|
+
return cls(allow=frozenset(caps))
|
|
42
|
+
|
|
43
|
+
def intersect(self, delegated: frozenset[str]) -> frozenset[str]:
|
|
44
|
+
"""Return the effective scope: delegated capabilities this policy allows."""
|
|
45
|
+
return delegated & self.allow
|
|
46
|
+
|
|
47
|
+
def permits(self, capability: str) -> bool:
|
|
48
|
+
return capability in self.allow
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Delegation provenance: linked records forming a tamper-evident delegation DAG.
|
|
2
|
+
|
|
3
|
+
Each delegation hop emits a ``DelegationRecord`` that references its parent
|
|
4
|
+
record by hash and names the credential it acted under. A chain of records is
|
|
5
|
+
verifiable offline: the parent link is the hash of the parent record's canonical
|
|
6
|
+
body, so tampering with any record (its scope, subject, or its own parent link)
|
|
7
|
+
changes its hash and breaks the child's link. This is the runtime-evidence side
|
|
8
|
+
of the cA2A profile (see docs/spec/trace-a2a-profile.md); the full TRACE binding
|
|
9
|
+
lands with the Tier 2 provenance work.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ca2a_runtime.delegation.credential import DelegationCredential, canonical_bytes
|
|
19
|
+
from ca2a_runtime.errors import ProvenanceLinkBroken
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class DelegationRecord:
|
|
24
|
+
"""A provenance record for a single delegation hop."""
|
|
25
|
+
|
|
26
|
+
record_id: str
|
|
27
|
+
credential_id: str
|
|
28
|
+
subject: str
|
|
29
|
+
scope: frozenset[str]
|
|
30
|
+
parent_record_hash: str | None = None
|
|
31
|
+
|
|
32
|
+
def body(self) -> dict[str, Any]:
|
|
33
|
+
"""The hashed portion of the record."""
|
|
34
|
+
return {
|
|
35
|
+
"record_id": self.record_id,
|
|
36
|
+
"credential_id": self.credential_id,
|
|
37
|
+
"subject": self.subject,
|
|
38
|
+
"scope": sorted(self.scope),
|
|
39
|
+
"parent_record_hash": self.parent_record_hash,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def record_hash(self) -> str:
|
|
43
|
+
"""SHA-256 over the canonical body. Any field change changes this value."""
|
|
44
|
+
return hashlib.sha256(canonical_bytes(self.body())).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def record_for(
|
|
48
|
+
credential: DelegationCredential,
|
|
49
|
+
record_id: str,
|
|
50
|
+
parent_record_hash: str | None,
|
|
51
|
+
) -> DelegationRecord:
|
|
52
|
+
"""Build the provenance record a hop emits for a delegation credential."""
|
|
53
|
+
return DelegationRecord(
|
|
54
|
+
record_id=record_id,
|
|
55
|
+
credential_id=credential.credential_id,
|
|
56
|
+
subject=credential.subject,
|
|
57
|
+
scope=credential.scope,
|
|
58
|
+
parent_record_hash=parent_record_hash,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def verify_dag(records: list[DelegationRecord]) -> list[DelegationRecord]:
|
|
63
|
+
"""Verify a root-to-leaf provenance chain and return it in order.
|
|
64
|
+
|
|
65
|
+
Raises ProvenanceLinkBroken on the first violation:
|
|
66
|
+
|
|
67
|
+
- the first record must be a root (no parent link);
|
|
68
|
+
- every later record's parent_record_hash must equal the recomputed hash of
|
|
69
|
+
the immediately preceding record (so a tampered or reparented record is
|
|
70
|
+
detected because its hash no longer matches the stored link);
|
|
71
|
+
- no record_id may repeat.
|
|
72
|
+
"""
|
|
73
|
+
if not records:
|
|
74
|
+
raise ProvenanceLinkBroken("empty provenance chain")
|
|
75
|
+
|
|
76
|
+
seen_ids: set[str] = set()
|
|
77
|
+
prev: DelegationRecord | None = None
|
|
78
|
+
|
|
79
|
+
for i, rec in enumerate(records):
|
|
80
|
+
if rec.record_id in seen_ids:
|
|
81
|
+
raise ProvenanceLinkBroken(f"duplicate record_id at position {i}: {rec.record_id}")
|
|
82
|
+
seen_ids.add(rec.record_id)
|
|
83
|
+
|
|
84
|
+
if prev is None:
|
|
85
|
+
if rec.parent_record_hash is not None:
|
|
86
|
+
raise ProvenanceLinkBroken("root record must not reference a parent")
|
|
87
|
+
else:
|
|
88
|
+
expected = prev.record_hash()
|
|
89
|
+
if rec.parent_record_hash != expected:
|
|
90
|
+
raise ProvenanceLinkBroken(
|
|
91
|
+
f"record {i} parent link does not match the previous record's hash",
|
|
92
|
+
detail="a tampered or reparented record was detected",
|
|
93
|
+
)
|
|
94
|
+
prev = rec
|
|
95
|
+
|
|
96
|
+
return records
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cross_check_chain(
|
|
100
|
+
records: list[DelegationRecord], chain: list[DelegationCredential]
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Confirm a verified provenance chain lines up with a delegation chain.
|
|
103
|
+
|
|
104
|
+
Ties provenance to authority: record ``i`` must reference credential ``i``
|
|
105
|
+
and carry the same subject. Raises ProvenanceLinkBroken on any mismatch.
|
|
106
|
+
"""
|
|
107
|
+
if len(records) != len(chain):
|
|
108
|
+
raise ProvenanceLinkBroken(
|
|
109
|
+
f"provenance length {len(records)} does not match chain length {len(chain)}"
|
|
110
|
+
)
|
|
111
|
+
for i, (rec, cred) in enumerate(zip(records, chain, strict=True)):
|
|
112
|
+
if rec.credential_id != cred.credential_id:
|
|
113
|
+
raise ProvenanceLinkBroken(f"record {i} credential_id does not match the chain")
|
|
114
|
+
if rec.subject != cred.subject:
|
|
115
|
+
raise ProvenanceLinkBroken(f"record {i} subject does not match the chain")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""TEE provider abstraction for peer attestation.
|
|
2
|
+
|
|
3
|
+
cA2A reuses the pluggable provider model from cmcp: a provider produces an
|
|
4
|
+
attestation report that binds a public key to a hardware measurement. Real
|
|
5
|
+
hardware backends are Tier 3 (see ROADMAP.md) and fail closed until implemented.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ca2a_runtime.tee.base import AttestationReport, BaseProvider
|
|
9
|
+
from ca2a_runtime.tee.sev_snp import SevSnpProvider, SevSnpReport
|
|
10
|
+
from ca2a_runtime.tee.tdx import TdxProvider, TdxQuote
|
|
11
|
+
from ca2a_runtime.tee.tpm import TpmProvider, TpmQuote
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AttestationReport",
|
|
15
|
+
"BaseProvider",
|
|
16
|
+
"SevSnpProvider",
|
|
17
|
+
"SevSnpReport",
|
|
18
|
+
"TdxProvider",
|
|
19
|
+
"TdxQuote",
|
|
20
|
+
"TpmProvider",
|
|
21
|
+
"TpmQuote",
|
|
22
|
+
]
|
ca2a_runtime/tee/base.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Base TEE provider interface and attestation report model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class AttestationReport:
|
|
11
|
+
"""An attestation report binding a public key to a hardware measurement."""
|
|
12
|
+
|
|
13
|
+
platform: str
|
|
14
|
+
measurement: str
|
|
15
|
+
public_key: str # raw hex of the key bound to this measurement
|
|
16
|
+
nonce: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseProvider(ABC):
|
|
20
|
+
"""Normalized interface every TEE provider implements.
|
|
21
|
+
|
|
22
|
+
Real hardware providers (TPM, SEV-SNP, TDX, OPAQUE) are Tier 3 and are not
|
|
23
|
+
implemented in this release; ``detect`` returns False for them so they are
|
|
24
|
+
never selected automatically.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
platform: str = "base"
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def detect(cls) -> bool:
|
|
32
|
+
"""Return True if this provider is available on the current host."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def attest(self, public_key: str, nonce: str) -> AttestationReport:
|
|
36
|
+
"""Produce an attestation report binding ``public_key`` under ``nonce``."""
|