capiscio-sdk 0.3.0__py3-none-any.whl → 2.3.0__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.
@@ -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
- from .compliance import ComplianceScorer
25
- from .trust import TrustScorer
26
- from .availability import AvailabilityScorer
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",
@@ -1,45 +1,66 @@
1
- """SimpleGuard: Local, zero-config security for A2A agents."""
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, cast
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
- The "Customs Officer" for your Agent.
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
- Enforces Identity (JWS) and Protocol (A2A) validation locally.
27
- Prioritizes local utility and zero-configuration.
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 and agent-card.json.
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 (Walk up logic)
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
- self.private_key_path = self.keys_dir / "private.pem"
57
- self.public_key_path = self.keys_dir / "public.pem"
58
-
59
- # 3. Load or Generate agent-card.json
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
- # 4. Load or Generate Keys
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
- # 5. Load Trust Store (and self-trust in dev mode)
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
- # Replay Protection: Inject timestamps
87
- now = int(time.time())
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
- # Integrity: Calculate Body Hash if body is provided
92
- if body is not None:
93
- # SHA-256 hash
94
- sha256_hash = hashlib.sha256(body).digest()
95
- # Base64Url encode (no padding)
96
- bh = base64.urlsafe_b64encode(sha256_hash).decode('utf-8').rstrip('=')
97
- payload["bh"] = bh
98
-
99
- # Prepare headers
100
- headers = {
101
- "kid": self.signing_kid,
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
- # 1. Parse Header to get kid (without verifying yet)
134
- header = jwt.get_unverified_header(jws)
135
- kid = header.get("kid")
136
-
137
- if not kid:
138
- raise VerificationError("Missing 'kid' in JWS header.")
139
-
140
- # 2. Resolution: Look for trusted key
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
- if now > (exp + CLOCK_SKEW_LEEWAY):
186
- logger.warning(f'{{"event": "agent_call_denied", "kid": "{kid}", "reason": "token_expired"}}')
187
- raise VerificationError("Token expired.")
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
- if now < (iat - CLOCK_SKEW_LEEWAY):
190
- logger.warning(f'{{"event": "agent_call_denied", "kid": "{kid}", "reason": "clock_skew"}}')
191
- raise VerificationError("Token not yet valid (Clock skew).")
192
-
193
- # 6. Observability
194
- iss = payload.get("iss", "unknown")
195
- logger.info(f'{{"event": "agent_call_allowed", "iss": "{iss}", "kid": "{kid}"}}')
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
- return payload
192
+ def close(self) -> None:
193
+ """Close the gRPC connection."""
194
+ if self._client:
195
+ self._client.close()
198
196
 
199
- except jwt.InvalidSignatureError:
200
- logger.warning(f'{{"event": "agent_call_denied", "kid": "{kid}", "reason": "invalid_signature"}}')
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 make_headers(self, payload: Dict[str, Any], body: Optional[bytes] = None) -> Dict[str, str]:
213
- """Helper to generate the headers containing the JWS."""
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
- raise ConfigurationError("agent-card.json missing 'public_keys'.")
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
- # Generate minimal card
253
- logger.info("Dev Mode: Generating minimal agent-card.json")
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 private.pem or generate it in dev_mode."""
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 self.private_key_path.exists():
272
- try:
273
- with open(self.private_key_path, "rb") as f:
274
- loaded_key = serialization.load_pem_private_key(
275
- f.read(), password=None
276
- )
277
- if not isinstance(loaded_key, ed25519.Ed25519PrivateKey):
278
- raise ConfigurationError("Private key is not an Ed25519 key.")
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
- # Save Private Key
287
- with open(self.private_key_path, "wb") as f:
288
- f.write(self._private_key.private_bytes(
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
- with open(self.public_key_path, "wb") as f:
301
- f.write(pem_public)
302
-
303
- # Now update agent-card.json with the JWK
304
- self._update_agent_card_with_jwk(public_key)
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 {self.private_key_path}")
297
+ raise ConfigurationError(f"private.pem not found at {private_key_path}")
307
298
 
308
- def _update_agent_card_with_jwk(self, public_key: ed25519.Ed25519PublicKey) -> None:
309
- """Helper to write the agent-card.json with the generated key."""
310
- # Convert Ed25519 public key to JWK parameters
311
- # Ed25519 keys are simple: x is the raw bytes
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": [jwk],
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: Copy public.pem to trusted/{kid}.pem
350
- self_trust_path = self.trusted_dir / f"{self.signing_kid}.pem"
351
- if not self_trust_path.exists() and self.public_key_path.exists():
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(self.public_key_path, self_trust_path)
354
- logger.info(f"Dev Mode: Added self-trust for kid {self.signing_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))