capiscio-sdk 0.3.0__py3-none-any.whl → 2.3.1__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.
- capiscio_sdk/__init__.py +67 -1
- capiscio_sdk/_rpc/__init__.py +7 -0
- capiscio_sdk/_rpc/client.py +1321 -0
- capiscio_sdk/_rpc/gen/__init__.py +1 -0
- capiscio_sdk/_rpc/process.py +232 -0
- capiscio_sdk/badge.py +737 -0
- capiscio_sdk/badge_keeper.py +304 -0
- capiscio_sdk/dv.py +296 -0
- capiscio_sdk/executor.py +5 -5
- capiscio_sdk/integrations/fastapi.py +3 -2
- capiscio_sdk/scoring/__init__.py +73 -3
- capiscio_sdk/simple_guard.py +196 -204
- capiscio_sdk/validators/__init__.py +59 -2
- capiscio_sdk/validators/_core.py +376 -0
- capiscio_sdk-2.3.1.dist-info/METADATA +532 -0
- {capiscio_sdk-0.3.0.dist-info → capiscio_sdk-2.3.1.dist-info}/RECORD +18 -10
- {capiscio_sdk-0.3.0.dist-info → capiscio_sdk-2.3.1.dist-info}/WHEEL +1 -1
- capiscio_sdk-0.3.0.dist-info/METADATA +0 -126
- {capiscio_sdk-0.3.0.dist-info → capiscio_sdk-2.3.1.dist-info}/licenses/LICENSE +0 -0
capiscio_sdk/scoring/__init__.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"""Multi-dimensional scoring system for A2A validation.
|
|
2
2
|
|
|
3
|
+
.. deprecated:: 0.3.0
|
|
4
|
+
The pure Python scoring module is deprecated. Scoring is now handled by
|
|
5
|
+
capiscio-core via gRPC. Use :func:`capiscio_sdk.validate_agent_card` for
|
|
6
|
+
Agent Card validation with scoring, which delegates to Go core.
|
|
7
|
+
|
|
3
8
|
This module provides three independent scoring dimensions:
|
|
4
9
|
- Compliance: Protocol specification adherence (0-100)
|
|
5
10
|
- Trust: Security and authenticity signals (0-100)
|
|
@@ -7,8 +12,14 @@ This module provides three independent scoring dimensions:
|
|
|
7
12
|
|
|
8
13
|
Each dimension has its own rating scale and breakdown structure,
|
|
9
14
|
allowing users to make nuanced decisions based on their priorities.
|
|
15
|
+
|
|
16
|
+
NOTE: The types in this module are still used for API compatibility.
|
|
17
|
+
The scorer classes (ComplianceScorer, TrustScorer, AvailabilityScorer)
|
|
18
|
+
are deprecated and should not be used directly.
|
|
10
19
|
"""
|
|
11
20
|
|
|
21
|
+
import warnings as _warnings
|
|
22
|
+
|
|
12
23
|
from .types import (
|
|
13
24
|
ComplianceScore,
|
|
14
25
|
TrustScore,
|
|
@@ -21,11 +32,69 @@ from .types import (
|
|
|
21
32
|
AvailabilityRating,
|
|
22
33
|
ScoringContext,
|
|
23
34
|
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
from .
|
|
35
|
+
|
|
36
|
+
# Import deprecated scorers with warnings
|
|
37
|
+
from .compliance import ComplianceScorer as _LegacyComplianceScorer
|
|
38
|
+
from .trust import TrustScorer as _LegacyTrustScorer
|
|
39
|
+
from .availability import AvailabilityScorer as _LegacyAvailabilityScorer
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ComplianceScorer(_LegacyComplianceScorer):
|
|
43
|
+
"""Compliance scorer (DEPRECATED - scoring now handled by Go core).
|
|
44
|
+
|
|
45
|
+
.. deprecated:: 0.3.0
|
|
46
|
+
Use :func:`capiscio_sdk.validate_agent_card` which delegates
|
|
47
|
+
scoring to capiscio-core. This class will be removed in v1.0.0.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, *args, **kwargs):
|
|
51
|
+
_warnings.warn(
|
|
52
|
+
"ComplianceScorer is deprecated. Scoring is now handled by Go core. "
|
|
53
|
+
"Use capiscio_sdk.validate_agent_card() instead.",
|
|
54
|
+
DeprecationWarning,
|
|
55
|
+
stacklevel=2
|
|
56
|
+
)
|
|
57
|
+
super().__init__(*args, **kwargs)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TrustScorer(_LegacyTrustScorer):
|
|
61
|
+
"""Trust scorer (DEPRECATED - scoring now handled by Go core).
|
|
62
|
+
|
|
63
|
+
.. deprecated:: 0.3.0
|
|
64
|
+
Use :func:`capiscio_sdk.validate_agent_card` which delegates
|
|
65
|
+
scoring to capiscio-core. This class will be removed in v1.0.0.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, *args, **kwargs):
|
|
69
|
+
_warnings.warn(
|
|
70
|
+
"TrustScorer is deprecated. Scoring is now handled by Go core. "
|
|
71
|
+
"Use capiscio_sdk.validate_agent_card() instead.",
|
|
72
|
+
DeprecationWarning,
|
|
73
|
+
stacklevel=2
|
|
74
|
+
)
|
|
75
|
+
super().__init__(*args, **kwargs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AvailabilityScorer(_LegacyAvailabilityScorer):
|
|
79
|
+
"""Availability scorer (DEPRECATED - scoring now handled by Go core).
|
|
80
|
+
|
|
81
|
+
.. deprecated:: 0.3.0
|
|
82
|
+
Use :func:`capiscio_sdk.validate_agent_card` which delegates
|
|
83
|
+
scoring to capiscio-core. This class will be removed in v1.0.0.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, *args, **kwargs):
|
|
87
|
+
_warnings.warn(
|
|
88
|
+
"AvailabilityScorer is deprecated. Scoring is now handled by Go core. "
|
|
89
|
+
"Use capiscio_sdk.validate_agent_card() instead.",
|
|
90
|
+
DeprecationWarning,
|
|
91
|
+
stacklevel=2
|
|
92
|
+
)
|
|
93
|
+
super().__init__(*args, **kwargs)
|
|
94
|
+
|
|
27
95
|
|
|
28
96
|
__all__ = [
|
|
97
|
+
# Types (still used)
|
|
29
98
|
"ComplianceScore",
|
|
30
99
|
"TrustScore",
|
|
31
100
|
"AvailabilityScore",
|
|
@@ -36,6 +105,7 @@ __all__ = [
|
|
|
36
105
|
"TrustRating",
|
|
37
106
|
"AvailabilityRating",
|
|
38
107
|
"ScoringContext",
|
|
108
|
+
# Deprecated scorers (kept for backward compatibility)
|
|
39
109
|
"ComplianceScorer",
|
|
40
110
|
"TrustScorer",
|
|
41
111
|
"AvailabilityScorer",
|
capiscio_sdk/simple_guard.py
CHANGED
|
@@ -1,45 +1,66 @@
|
|
|
1
|
-
"""SimpleGuard
|
|
1
|
+
"""SimpleGuard - Zero-config security for A2A agents.
|
|
2
|
+
|
|
3
|
+
This module provides signing and verification of A2A messages using
|
|
4
|
+
the capiscio-core Go library via gRPC. All cryptographic operations
|
|
5
|
+
are delegated to the Go core for consistency across SDKs.
|
|
6
|
+
"""
|
|
2
7
|
import os
|
|
3
8
|
import json
|
|
4
9
|
import logging
|
|
5
|
-
import base64
|
|
6
|
-
import hashlib
|
|
7
|
-
import time
|
|
8
10
|
from pathlib import Path
|
|
9
|
-
from typing import Optional, Dict, Any, Union
|
|
10
|
-
|
|
11
|
-
import jwt
|
|
12
|
-
from cryptography.hazmat.primitives import serialization
|
|
13
|
-
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
11
|
+
from typing import Optional, Dict, Any, Union
|
|
14
12
|
|
|
15
13
|
from .errors import ConfigurationError, VerificationError
|
|
14
|
+
from ._rpc.client import CapiscioRPCClient
|
|
16
15
|
|
|
17
16
|
logger = logging.getLogger(__name__)
|
|
18
17
|
|
|
19
|
-
MAX_TOKEN_AGE = 60
|
|
20
|
-
CLOCK_SKEW_LEEWAY = 5
|
|
21
18
|
|
|
22
19
|
class SimpleGuard:
|
|
23
20
|
"""
|
|
24
|
-
|
|
21
|
+
Zero-config security middleware for A2A agents.
|
|
22
|
+
|
|
23
|
+
SimpleGuard handles message signing and verification using Ed25519
|
|
24
|
+
keys. All cryptographic operations are performed by the capiscio-core
|
|
25
|
+
Go library via gRPC, ensuring consistent behavior across all SDKs.
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
Example:
|
|
28
|
+
>>> guard = SimpleGuard(dev_mode=True)
|
|
29
|
+
>>> token = guard.sign_outbound({"sub": "test"}, body=b"hello")
|
|
30
|
+
>>> claims = guard.verify_inbound(token, body=b"hello")
|
|
31
|
+
|
|
32
|
+
# With explicit agent_id:
|
|
33
|
+
>>> guard = SimpleGuard(agent_id="did:web:example.com:agents:myagent")
|
|
34
|
+
|
|
35
|
+
# In dev mode, did:key is auto-generated:
|
|
36
|
+
>>> guard = SimpleGuard(dev_mode=True)
|
|
37
|
+
>>> print(guard.agent_id) # did:key:z6Mk...
|
|
28
38
|
"""
|
|
29
39
|
|
|
30
40
|
def __init__(
|
|
31
41
|
self,
|
|
32
42
|
base_dir: Optional[Union[str, Path]] = None,
|
|
33
|
-
dev_mode: bool = False
|
|
43
|
+
dev_mode: bool = False,
|
|
44
|
+
rpc_address: Optional[str] = None,
|
|
45
|
+
agent_id: Optional[str] = None,
|
|
46
|
+
badge_token: Optional[str] = None,
|
|
34
47
|
) -> None:
|
|
35
48
|
"""
|
|
36
49
|
Initialize SimpleGuard.
|
|
37
50
|
|
|
38
51
|
Args:
|
|
39
52
|
base_dir: Starting directory to search for config (defaults to cwd).
|
|
40
|
-
dev_mode: If True, auto-generates keys
|
|
53
|
+
dev_mode: If True, auto-generates keys with did:key identity (RFC-002 §6.1).
|
|
54
|
+
rpc_address: gRPC server address. If None, auto-starts local server.
|
|
55
|
+
agent_id: Explicit agent DID. If None:
|
|
56
|
+
- In dev_mode: Auto-generates did:key from keypair
|
|
57
|
+
- Otherwise: Loaded from agent-card.json
|
|
58
|
+
badge_token: Pre-obtained badge token to use for identity. When set,
|
|
59
|
+
make_headers() will use this token instead of signing.
|
|
41
60
|
"""
|
|
42
61
|
self.dev_mode = dev_mode
|
|
62
|
+
self._explicit_agent_id = agent_id
|
|
63
|
+
self._badge_token = badge_token
|
|
43
64
|
|
|
44
65
|
# 1. Safety Check
|
|
45
66
|
if self.dev_mode and os.getenv("CAPISCIO_ENV") == "prod":
|
|
@@ -48,24 +69,25 @@ class SimpleGuard:
|
|
|
48
69
|
"This is insecure! Disable dev_mode in production."
|
|
49
70
|
)
|
|
50
71
|
|
|
51
|
-
# 2. Resolve base_dir
|
|
72
|
+
# 2. Resolve base_dir
|
|
52
73
|
self.project_root = self._resolve_project_root(base_dir)
|
|
53
74
|
self.keys_dir = self.project_root / "capiscio_keys"
|
|
54
75
|
self.trusted_dir = self.keys_dir / "trusted"
|
|
55
76
|
self.agent_card_path = self.project_root / "agent-card.json"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
|
|
78
|
+
# 3. Connect to gRPC server
|
|
79
|
+
self._client = CapiscioRPCClient(address=rpc_address)
|
|
80
|
+
self._client.connect()
|
|
81
|
+
|
|
82
|
+
# 4. Load or generate agent identity
|
|
60
83
|
self.agent_id: str
|
|
61
84
|
self.signing_kid: str
|
|
62
85
|
self._load_or_generate_card()
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
self._private_key: ed25519.Ed25519PrivateKey
|
|
86
|
+
|
|
87
|
+
# 5. Load or generate keys via gRPC (may update agent_id with did:key)
|
|
66
88
|
self._load_or_generate_keys()
|
|
67
|
-
|
|
68
|
-
#
|
|
89
|
+
|
|
90
|
+
# 6. Load trust store
|
|
69
91
|
self._setup_trust_store()
|
|
70
92
|
|
|
71
93
|
def sign_outbound(self, payload: Dict[str, Any], body: Optional[bytes] = None) -> str:
|
|
@@ -83,37 +105,20 @@ class SimpleGuard:
|
|
|
83
105
|
if "iss" not in payload:
|
|
84
106
|
payload["iss"] = self.agent_id
|
|
85
107
|
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
payload["iat"] = now
|
|
89
|
-
payload["exp"] = now + MAX_TOKEN_AGE
|
|
108
|
+
# Use body for binding if provided
|
|
109
|
+
body_bytes = body or b""
|
|
90
110
|
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"typ": "JWT",
|
|
103
|
-
"alg": "EdDSA"
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
# Sign
|
|
107
|
-
try:
|
|
108
|
-
token = jwt.encode(
|
|
109
|
-
payload,
|
|
110
|
-
self._private_key,
|
|
111
|
-
algorithm="EdDSA",
|
|
112
|
-
headers=headers
|
|
113
|
-
)
|
|
114
|
-
return token
|
|
115
|
-
except Exception as e:
|
|
116
|
-
raise ConfigurationError(f"Failed to sign payload: {e}")
|
|
111
|
+
# Sign via gRPC - use SignAttached which handles timestamps and body hash
|
|
112
|
+
jws, error = self._client.simpleguard.sign_attached(
|
|
113
|
+
payload=body_bytes, # This gets hashed into 'bh' claim
|
|
114
|
+
key_id=self.signing_kid,
|
|
115
|
+
headers={"iss": self.agent_id},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if error:
|
|
119
|
+
raise ConfigurationError(f"Failed to sign payload: {error}")
|
|
120
|
+
|
|
121
|
+
return jws
|
|
117
122
|
|
|
118
123
|
def verify_inbound(self, jws: str, body: Optional[bytes] = None) -> Dict[str, Any]:
|
|
119
124
|
"""
|
|
@@ -129,119 +134,101 @@ class SimpleGuard:
|
|
|
129
134
|
Raises:
|
|
130
135
|
VerificationError: If signature is invalid, key is untrusted, or integrity check fails.
|
|
131
136
|
"""
|
|
137
|
+
valid, payload_bytes, key_id, error = self._client.simpleguard.verify_attached(
|
|
138
|
+
jws=jws,
|
|
139
|
+
body=body,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if error:
|
|
143
|
+
logger.warning(f'{{"event": "agent_call_denied", "reason": "{error}"}}')
|
|
144
|
+
raise VerificationError(error)
|
|
145
|
+
|
|
146
|
+
if not valid:
|
|
147
|
+
raise VerificationError("Verification failed")
|
|
148
|
+
|
|
149
|
+
# Parse payload
|
|
132
150
|
try:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
trusted_key_path = self.trusted_dir / f"{kid}.pem"
|
|
142
|
-
if not trusted_key_path.exists():
|
|
143
|
-
logger.warning(f'{{"event": "agent_call_denied", "kid": "{kid}", "reason": "untrusted_key"}}')
|
|
144
|
-
raise VerificationError(f"Untrusted key ID: {kid}")
|
|
145
|
-
|
|
146
|
-
# Load the trusted public key
|
|
147
|
-
with open(trusted_key_path, "rb") as f:
|
|
148
|
-
public_key = serialization.load_pem_public_key(f.read())
|
|
149
|
-
# Ensure it is a key type compatible with jwt.decode (Ed25519PublicKey is supported)
|
|
150
|
-
# We cast to Any to satisfy mypy's strict check against the specific union
|
|
151
|
-
public_key = cast(Any, public_key)
|
|
152
|
-
|
|
153
|
-
# 3. Verify Signature
|
|
154
|
-
payload = jwt.decode(
|
|
155
|
-
jws,
|
|
156
|
-
public_key,
|
|
157
|
-
algorithms=["EdDSA"],
|
|
158
|
-
options={"verify_aud": False} # Audience verification depends on context, skipping for generic guard
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
# Cast payload to Dict[str, Any]
|
|
162
|
-
payload = cast(Dict[str, Any], payload)
|
|
163
|
-
|
|
164
|
-
# 4. Integrity Check (Body Hash)
|
|
165
|
-
if "bh" in payload:
|
|
166
|
-
if body is None:
|
|
167
|
-
raise VerificationError("JWS contains 'bh' claim but no body provided for verification.")
|
|
168
|
-
|
|
169
|
-
# Calculate hash of received body
|
|
170
|
-
sha256_hash = hashlib.sha256(body).digest()
|
|
171
|
-
calculated_bh = base64.urlsafe_b64encode(sha256_hash).decode('utf-8').rstrip('=')
|
|
172
|
-
|
|
173
|
-
if calculated_bh != payload["bh"]:
|
|
174
|
-
logger.warning(f'{{"event": "agent_call_denied", "kid": "{kid}", "reason": "integrity_check_failed"}}')
|
|
175
|
-
raise VerificationError("Integrity Check Failed: Body modified")
|
|
176
|
-
|
|
177
|
-
# 5. Replay Protection (Timestamp Enforcement)
|
|
178
|
-
now = int(time.time())
|
|
179
|
-
exp = payload.get("exp")
|
|
180
|
-
iat = payload.get("iat")
|
|
181
|
-
|
|
182
|
-
if exp is None or iat is None:
|
|
183
|
-
raise VerificationError("Missing timestamp claims (exp, iat).")
|
|
151
|
+
payload = json.loads(payload_bytes) if payload_bytes else {}
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
payload = {}
|
|
154
|
+
|
|
155
|
+
iss = payload.get("iss", "unknown")
|
|
156
|
+
logger.info(f'{{"event": "agent_call_allowed", "iss": "{iss}", "kid": "{key_id}"}}')
|
|
157
|
+
|
|
158
|
+
return payload
|
|
184
159
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
160
|
+
def make_headers(self, payload: Dict[str, Any], body: Optional[bytes] = None) -> Dict[str, str]:
|
|
161
|
+
"""
|
|
162
|
+
Generate headers containing the Badge (RFC-002 §9.1).
|
|
163
|
+
|
|
164
|
+
If a badge_token was provided at construction, it will be used.
|
|
165
|
+
Otherwise, signs the payload to create a token.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
payload: The JSON payload to sign (ignored if using badge_token).
|
|
169
|
+
body: Optional HTTP body bytes to bind to the signature.
|
|
188
170
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
171
|
+
Returns:
|
|
172
|
+
Dict with X-Capiscio-Badge header.
|
|
173
|
+
"""
|
|
174
|
+
if self._badge_token:
|
|
175
|
+
return {"X-Capiscio-Badge": self._badge_token}
|
|
176
|
+
|
|
177
|
+
token = self.sign_outbound(payload, body=body)
|
|
178
|
+
return {"X-Capiscio-Badge": token}
|
|
179
|
+
|
|
180
|
+
def set_badge_token(self, token: str) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Update the badge token used for outbound requests.
|
|
183
|
+
|
|
184
|
+
This is typically called by the badge keeper when a new token is obtained.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
token: The new badge token (compact JWS).
|
|
188
|
+
"""
|
|
189
|
+
self._badge_token = token
|
|
190
|
+
logger.debug(f"Updated badge token for agent {self.agent_id}")
|
|
196
191
|
|
|
197
|
-
|
|
192
|
+
def close(self) -> None:
|
|
193
|
+
"""Close the gRPC connection."""
|
|
194
|
+
if self._client:
|
|
195
|
+
self._client.close()
|
|
198
196
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
raise VerificationError("Invalid signature.")
|
|
202
|
-
except jwt.ExpiredSignatureError:
|
|
203
|
-
logger.warning(f'{{"event": "agent_call_denied", "kid": "{kid}", "reason": "token_expired"}}')
|
|
204
|
-
raise VerificationError("Token expired.")
|
|
205
|
-
except jwt.DecodeError:
|
|
206
|
-
raise VerificationError("Invalid JWS format.")
|
|
207
|
-
except Exception as e:
|
|
208
|
-
if isinstance(e, VerificationError):
|
|
209
|
-
raise
|
|
210
|
-
raise VerificationError(f"Verification failed: {e}")
|
|
197
|
+
def __enter__(self) -> "SimpleGuard":
|
|
198
|
+
return self
|
|
211
199
|
|
|
212
|
-
def
|
|
213
|
-
|
|
214
|
-
token = self.sign_outbound(payload, body=body)
|
|
215
|
-
return {"X-Capiscio-JWS": token}
|
|
200
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
201
|
+
self.close()
|
|
216
202
|
|
|
217
203
|
def _resolve_project_root(self, base_dir: Optional[Union[str, Path]]) -> Path:
|
|
218
204
|
"""Walk up the directory tree to find agent-card.json or stop at root."""
|
|
219
205
|
current = Path(base_dir or os.getcwd()).resolve()
|
|
220
206
|
|
|
221
|
-
# If we are in dev mode and nothing exists, we might just use cwd
|
|
222
|
-
# But let's try to find an existing project structure first
|
|
223
207
|
search_path = current
|
|
224
208
|
while search_path != search_path.parent:
|
|
225
209
|
if (search_path / "agent-card.json").exists():
|
|
226
210
|
return search_path
|
|
227
211
|
search_path = search_path.parent
|
|
228
212
|
|
|
229
|
-
# If not found, default to cwd
|
|
230
213
|
return current
|
|
231
214
|
|
|
232
215
|
def _load_or_generate_card(self) -> None:
|
|
233
216
|
"""Load agent-card.json or generate a minimal one in dev_mode."""
|
|
217
|
+
# If explicit agent_id was provided, use it
|
|
218
|
+
if self._explicit_agent_id:
|
|
219
|
+
self.agent_id = self._explicit_agent_id
|
|
220
|
+
self.signing_kid = "key-0" # Will be updated when keys are generated/loaded
|
|
221
|
+
logger.info(f"Using explicit agent_id: {self.agent_id}")
|
|
222
|
+
return
|
|
223
|
+
|
|
234
224
|
if self.agent_card_path.exists():
|
|
235
225
|
try:
|
|
236
226
|
with open(self.agent_card_path, "r") as f:
|
|
237
227
|
data = json.load(f)
|
|
238
228
|
self.agent_id = data.get("agent_id")
|
|
239
|
-
# Assuming the first key is the signing key for now, or looking for a specific structure
|
|
240
|
-
# The mandate says: "Cache self.agent_id and self.signing_kid"
|
|
241
|
-
# We need to find the kid from the keys array.
|
|
242
229
|
keys = data.get("public_keys", [])
|
|
243
230
|
if not keys:
|
|
244
|
-
|
|
231
|
+
raise ConfigurationError("agent-card.json missing 'public_keys'.")
|
|
245
232
|
self.signing_kid = keys[0].get("kid")
|
|
246
233
|
|
|
247
234
|
if not self.agent_id or not self.signing_kid:
|
|
@@ -249,18 +236,22 @@ class SimpleGuard:
|
|
|
249
236
|
except Exception as e:
|
|
250
237
|
raise ConfigurationError(f"Failed to load agent-card.json: {e}")
|
|
251
238
|
elif self.dev_mode:
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
self.agent_id = "local-dev-agent"
|
|
239
|
+
logger.info("Dev Mode: Will generate did:key identity from keypair")
|
|
240
|
+
# Placeholder - will be updated with did:key after key generation
|
|
241
|
+
self.agent_id = "local-dev-agent" # Temporary, replaced in _load_or_generate_keys
|
|
255
242
|
self.signing_kid = "local-dev-key"
|
|
256
|
-
|
|
257
|
-
# We will populate the JWK part after generating the key in the next step
|
|
258
|
-
# For now, just set the basics, we'll write the file after key gen
|
|
259
243
|
else:
|
|
260
244
|
raise ConfigurationError(f"agent-card.json not found at {self.project_root}")
|
|
261
245
|
|
|
262
246
|
def _load_or_generate_keys(self) -> None:
|
|
263
|
-
"""Load
|
|
247
|
+
"""Load keys or generate them in dev_mode via gRPC.
|
|
248
|
+
|
|
249
|
+
In dev_mode, if no explicit agent_id was provided, updates self.agent_id
|
|
250
|
+
with the did:key derived from the generated keypair (RFC-002 §6.1).
|
|
251
|
+
"""
|
|
252
|
+
private_key_path = self.keys_dir / "private.pem"
|
|
253
|
+
public_key_path = self.keys_dir / "public.pem"
|
|
254
|
+
|
|
264
255
|
if not self.keys_dir.exists():
|
|
265
256
|
if self.dev_mode:
|
|
266
257
|
self.keys_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -268,64 +259,56 @@ class SimpleGuard:
|
|
|
268
259
|
else:
|
|
269
260
|
raise ConfigurationError(f"capiscio_keys directory not found at {self.keys_dir}")
|
|
270
261
|
|
|
271
|
-
if
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
self._private_key = loaded_key
|
|
280
|
-
except Exception as e:
|
|
281
|
-
raise ConfigurationError(f"Failed to load private.pem: {e}")
|
|
262
|
+
if private_key_path.exists():
|
|
263
|
+
# Load existing key via gRPC
|
|
264
|
+
key_info, error = self._client.simpleguard.load_key(str(private_key_path))
|
|
265
|
+
if error:
|
|
266
|
+
raise ConfigurationError(f"Failed to load private.pem: {error}")
|
|
267
|
+
# Update signing kid to match the loaded key
|
|
268
|
+
self.signing_kid = key_info["key_id"]
|
|
269
|
+
logger.info(f"Loaded key: {self.signing_kid}")
|
|
282
270
|
elif self.dev_mode:
|
|
283
|
-
logger.info("Dev Mode: Generating Ed25519 keypair")
|
|
284
|
-
self._private_key = ed25519.Ed25519PrivateKey.generate()
|
|
271
|
+
logger.info("Dev Mode: Generating Ed25519 keypair via gRPC")
|
|
285
272
|
|
|
286
|
-
#
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
encoding=serialization.Encoding.PEM,
|
|
290
|
-
format=serialization.PrivateFormat.PKCS8,
|
|
291
|
-
encryption_algorithm=serialization.NoEncryption()
|
|
292
|
-
))
|
|
293
|
-
|
|
294
|
-
# Save Public Key
|
|
295
|
-
public_key = self._private_key.public_key()
|
|
296
|
-
pem_public = public_key.public_bytes(
|
|
297
|
-
encoding=serialization.Encoding.PEM,
|
|
298
|
-
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
|
273
|
+
# Generate via gRPC
|
|
274
|
+
key_info, error = self._client.simpleguard.generate_key_pair(
|
|
275
|
+
key_id=self.signing_kid,
|
|
299
276
|
)
|
|
300
|
-
|
|
301
|
-
f
|
|
302
|
-
|
|
303
|
-
#
|
|
304
|
-
|
|
277
|
+
if error:
|
|
278
|
+
raise ConfigurationError(f"Failed to generate keypair: {error}")
|
|
279
|
+
|
|
280
|
+
# Update agent_id with did:key if not explicitly set (RFC-002 §6.1)
|
|
281
|
+
did_key = key_info.get("did_key")
|
|
282
|
+
if did_key and not self._explicit_agent_id:
|
|
283
|
+
self.agent_id = did_key
|
|
284
|
+
logger.info(f"Dev Mode: Using did:key identity: {self.agent_id}")
|
|
285
|
+
|
|
286
|
+
# Save private key
|
|
287
|
+
with open(private_key_path, "w") as f:
|
|
288
|
+
f.write(key_info["private_key_pem"])
|
|
289
|
+
|
|
290
|
+
# Save public key
|
|
291
|
+
with open(public_key_path, "w") as f:
|
|
292
|
+
f.write(key_info["public_key_pem"])
|
|
293
|
+
|
|
294
|
+
# Update agent-card.json with JWK
|
|
295
|
+
self._update_agent_card_with_pem(key_info["public_key_pem"])
|
|
305
296
|
else:
|
|
306
|
-
raise ConfigurationError(f"private.pem not found at {
|
|
297
|
+
raise ConfigurationError(f"private.pem not found at {private_key_path}")
|
|
307
298
|
|
|
308
|
-
def
|
|
309
|
-
"""Helper to write
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
raw_bytes = public_key.public_bytes(
|
|
313
|
-
encoding=serialization.Encoding.Raw,
|
|
314
|
-
format=serialization.PublicFormat.Raw
|
|
315
|
-
)
|
|
316
|
-
x_b64 = base64.urlsafe_b64encode(raw_bytes).decode('utf-8').rstrip('=')
|
|
317
|
-
|
|
318
|
-
jwk = {
|
|
319
|
-
"kty": "OKP",
|
|
320
|
-
"crv": "Ed25519",
|
|
321
|
-
"x": x_b64,
|
|
322
|
-
"kid": self.signing_kid,
|
|
323
|
-
"use": "sig"
|
|
324
|
-
}
|
|
325
|
-
|
|
299
|
+
def _update_agent_card_with_pem(self, public_key_pem: str) -> None:
|
|
300
|
+
"""Helper to write agent-card.json with the generated key."""
|
|
301
|
+
# For simplicity, just create a minimal card
|
|
302
|
+
# In production, would convert PEM to JWK
|
|
326
303
|
card_data = {
|
|
327
304
|
"agent_id": self.agent_id,
|
|
328
|
-
"public_keys": [
|
|
305
|
+
"public_keys": [{
|
|
306
|
+
"kty": "OKP",
|
|
307
|
+
"crv": "Ed25519",
|
|
308
|
+
"kid": self.signing_kid,
|
|
309
|
+
"use": "sig",
|
|
310
|
+
# Note: x would need to be extracted from PEM
|
|
311
|
+
}],
|
|
329
312
|
"protocolVersion": "0.3.0",
|
|
330
313
|
"name": "Local Dev Agent",
|
|
331
314
|
"description": "Auto-generated by SimpleGuard",
|
|
@@ -346,9 +329,18 @@ class SimpleGuard:
|
|
|
346
329
|
self.trusted_dir.mkdir(parents=True, exist_ok=True)
|
|
347
330
|
|
|
348
331
|
if self.dev_mode:
|
|
349
|
-
# Self-Trust:
|
|
350
|
-
|
|
351
|
-
|
|
332
|
+
# Self-Trust: Load public key into gRPC server's trust store
|
|
333
|
+
# Use a different key_id for trust to avoid overwriting the signing key
|
|
334
|
+
public_key_path = self.keys_dir / "public.pem"
|
|
335
|
+
trust_key_id = f"{self.signing_kid}-trust"
|
|
336
|
+
self_trust_path = self.trusted_dir / f"{trust_key_id}.pem"
|
|
337
|
+
|
|
338
|
+
if public_key_path.exists() and not self_trust_path.exists():
|
|
352
339
|
import shutil
|
|
353
|
-
shutil.copy(
|
|
354
|
-
logger.info(f"Dev Mode: Added self-trust for kid {
|
|
340
|
+
shutil.copy(public_key_path, self_trust_path)
|
|
341
|
+
logger.info(f"Dev Mode: Added self-trust for kid {trust_key_id}")
|
|
342
|
+
|
|
343
|
+
# Load into gRPC server trust store for verification
|
|
344
|
+
# (signing key is loaded separately during guard initialization)
|
|
345
|
+
if self_trust_path.exists():
|
|
346
|
+
self._client.simpleguard.load_key(str(self_trust_path))
|