starkgate-sdk 0.2.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.
- starkgate/__init__.py +42 -0
- starkgate/client.py +388 -0
- starkgate/ed25519.py +58 -0
- starkgate/engine.py +890 -0
- starkgate/langchain_guard.py +93 -0
- starkgate/local_audit.py +117 -0
- starkgate/mcp_server.py +207 -0
- starkgate/models.py +98 -0
- starkgate/normalization.py +193 -0
- starkgate/openai_guard.py +148 -0
- starkgate/starkgate_engine.wasm +0 -0
- starkgate/wasm_native.py +243 -0
- starkgate_sdk-0.2.0.dist-info/METADATA +202 -0
- starkgate_sdk-0.2.0.dist-info/RECORD +17 -0
- starkgate_sdk-0.2.0.dist-info/WHEEL +5 -0
- starkgate_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- starkgate_sdk-0.2.0.dist-info/top_level.txt +1 -0
starkgate/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from starkgate.client import StarkGate
|
|
2
|
+
from starkgate.models import EvaluationResult, Policy, Rule
|
|
3
|
+
from starkgate.ed25519 import (
|
|
4
|
+
sign_ed25519,
|
|
5
|
+
verify_ed25519,
|
|
6
|
+
ed25519_public_key,
|
|
7
|
+
)
|
|
8
|
+
from starkgate.local_audit import (
|
|
9
|
+
audit_hash,
|
|
10
|
+
chain_hash,
|
|
11
|
+
verify_chain,
|
|
12
|
+
verify_audit_entry,
|
|
13
|
+
verify_verdict_local,
|
|
14
|
+
)
|
|
15
|
+
from starkgate.engine import (
|
|
16
|
+
evaluate as evaluate,
|
|
17
|
+
evaluate_condition,
|
|
18
|
+
evaluate_operator,
|
|
19
|
+
get_nested_value,
|
|
20
|
+
StarkGateLocalError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"StarkGate",
|
|
26
|
+
"EvaluationResult",
|
|
27
|
+
"Policy",
|
|
28
|
+
"Rule",
|
|
29
|
+
"sign_ed25519",
|
|
30
|
+
"verify_ed25519",
|
|
31
|
+
"ed25519_public_key",
|
|
32
|
+
"audit_hash",
|
|
33
|
+
"chain_hash",
|
|
34
|
+
"verify_chain",
|
|
35
|
+
"verify_audit_entry",
|
|
36
|
+
"verify_verdict_local",
|
|
37
|
+
"evaluate",
|
|
38
|
+
"evaluate_condition",
|
|
39
|
+
"evaluate_operator",
|
|
40
|
+
"get_nested_value",
|
|
41
|
+
"StarkGateLocalError",
|
|
42
|
+
]
|
starkgate/client.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import hmac as hmac_module
|
|
7
|
+
import inspect
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .models import EvaluationResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StarkGateError(Exception):
|
|
18
|
+
"""Base exception for all StarkGate SDK errors."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StarkGateDenied(StarkGateError):
|
|
22
|
+
"""Raised by protect() when StarkGate policy DENIES an action."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str = "Action denied by StarkGate policy.",
|
|
27
|
+
result: Optional[EvaluationResult] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.result = result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StarkGateAPIError(StarkGateError):
|
|
34
|
+
"""Raised when the StarkGate API returns an error or the request fails."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
status_code: Optional[int] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.status_code = status_code
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StarkGate:
|
|
46
|
+
"""Python client for the StarkGate API — The Universal Firewall for AI Agents."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
api_key: str,
|
|
51
|
+
base_url: str = "https://starkgate-api.wenjoseph16.workers.dev",
|
|
52
|
+
timeout: float = 5.0,
|
|
53
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
54
|
+
signing_key: Optional[str] = None,
|
|
55
|
+
strict_mode: bool = False,
|
|
56
|
+
user_agent: str = "Mozilla/5.0 (compatible; starkgate-sdk/0.2.0)",
|
|
57
|
+
) -> None:
|
|
58
|
+
self.api_key = api_key
|
|
59
|
+
self.base_url = base_url.rstrip("/")
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.signing_key = signing_key
|
|
62
|
+
self.strict_mode = strict_mode
|
|
63
|
+
self._transport = transport
|
|
64
|
+
self.user_agent = user_agent
|
|
65
|
+
# NB : un User-Agent non-navigateur est bloqué par le WAF Cloudflare
|
|
66
|
+
# (erreur 1010). On envoie donc un UA compatible navigateur par défaut,
|
|
67
|
+
# surchargeable par l'appelant via le paramètre user_agent.
|
|
68
|
+
self._client = httpx.Client(
|
|
69
|
+
base_url=self.base_url,
|
|
70
|
+
headers={
|
|
71
|
+
"Authorization": f"Bearer {api_key}",
|
|
72
|
+
"User-Agent": user_agent,
|
|
73
|
+
},
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
transport=transport,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
self._client.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "StarkGate":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc: Any) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
88
|
+
try:
|
|
89
|
+
response = self._client.request(method, path, **kwargs)
|
|
90
|
+
except httpx.HTTPError as exc:
|
|
91
|
+
raise StarkGateAPIError(f"Request failed: {exc}") from exc
|
|
92
|
+
return self._parse(response)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _anti_replay() -> Dict[str, str]:
|
|
96
|
+
return {
|
|
97
|
+
"nonce": uuid.uuid4().hex,
|
|
98
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _canonicalize(fields: Dict[str, Any]) -> str:
|
|
103
|
+
parts: list[str] = []
|
|
104
|
+
for key in sorted(fields):
|
|
105
|
+
value = fields[key]
|
|
106
|
+
if value is None:
|
|
107
|
+
continue
|
|
108
|
+
if isinstance(value, bool):
|
|
109
|
+
value = "true" if value else "false"
|
|
110
|
+
elif isinstance(value, (dict, list)):
|
|
111
|
+
value = StarkGate._canonical_json(value)
|
|
112
|
+
else:
|
|
113
|
+
value = str(value)
|
|
114
|
+
parts.append(f"{key}={value}")
|
|
115
|
+
return "&".join(parts)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _canonical_json(value: Any) -> str:
|
|
119
|
+
"""JSON déterministe (F2) — mêmes règles que `canonical_json` (Rust)
|
|
120
|
+
et `canonicalNested` (TS) : clés triées récursivement, compact, UTF-8."""
|
|
121
|
+
if isinstance(value, bool):
|
|
122
|
+
return "true" if value else "false"
|
|
123
|
+
if value is None:
|
|
124
|
+
return "null"
|
|
125
|
+
if isinstance(value, dict):
|
|
126
|
+
inner = ",".join(
|
|
127
|
+
f"{json.dumps(k, ensure_ascii=False, separators=(',', ':'))}"
|
|
128
|
+
f":{StarkGate._canonical_json(v)}"
|
|
129
|
+
for k, v in sorted(value.items(), key=lambda kv: kv[0])
|
|
130
|
+
)
|
|
131
|
+
return "{" + inner + "}"
|
|
132
|
+
if isinstance(value, list):
|
|
133
|
+
inner = ",".join(StarkGate._canonical_json(v) for v in value)
|
|
134
|
+
return "[" + inner + "]"
|
|
135
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _normalize_hash(value: str, prefix: str) -> str:
|
|
139
|
+
return value[len(prefix) :] if value.startswith(prefix) else value
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def verify_merkle_proof(
|
|
143
|
+
leaf: str,
|
|
144
|
+
index: int,
|
|
145
|
+
siblings: list,
|
|
146
|
+
root: str,
|
|
147
|
+
) -> bool:
|
|
148
|
+
"""Recontruit la racine de Merkle à partir d'une feuille et vérifie la preuve."""
|
|
149
|
+
acc = bytes.fromhex(StarkGate._normalize_hash(leaf, "sha256:"))
|
|
150
|
+
cursor = index
|
|
151
|
+
for sibling in siblings:
|
|
152
|
+
sibling_bytes = bytes.fromhex(StarkGate._normalize_hash(sibling, "sha256:"))
|
|
153
|
+
digester = hashlib.sha256()
|
|
154
|
+
if cursor % 2 == 0:
|
|
155
|
+
digester.update(acc)
|
|
156
|
+
digester.update(sibling_bytes)
|
|
157
|
+
else:
|
|
158
|
+
digester.update(sibling_bytes)
|
|
159
|
+
digester.update(acc)
|
|
160
|
+
acc = digester.digest()
|
|
161
|
+
cursor //= 2
|
|
162
|
+
expected = StarkGate._normalize_hash(root, "merkle:")
|
|
163
|
+
return hmac_module.compare_digest(acc.hex(), expected)
|
|
164
|
+
|
|
165
|
+
def verify(self, verdict: Dict[str, Any]) -> bool:
|
|
166
|
+
"""Recalculates and verifies the HMAC signature of a signed verdict."""
|
|
167
|
+
if not self.signing_key:
|
|
168
|
+
raise StarkGateError("Provide signing_key to verify()")
|
|
169
|
+
signature = verdict.get("signature", "")
|
|
170
|
+
signed = verdict.get("signed")
|
|
171
|
+
if not isinstance(signed, dict):
|
|
172
|
+
return False
|
|
173
|
+
message = self._canonicalize(signed)
|
|
174
|
+
expected_hex = hmac_module.new(
|
|
175
|
+
self.signing_key.encode(), message.encode(), hashlib.sha256
|
|
176
|
+
).hexdigest()
|
|
177
|
+
provided_hex = signature[5:] if signature.startswith("hmac:") else signature
|
|
178
|
+
return hmac_module.compare_digest(expected_hex, provided_hex)
|
|
179
|
+
|
|
180
|
+
async def _async_request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
181
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
182
|
+
try:
|
|
183
|
+
async with httpx.AsyncClient(
|
|
184
|
+
base_url=self.base_url,
|
|
185
|
+
headers=headers,
|
|
186
|
+
timeout=self.timeout,
|
|
187
|
+
transport=self._transport,
|
|
188
|
+
) as client:
|
|
189
|
+
response = await client.request(method, path, **kwargs)
|
|
190
|
+
except httpx.HTTPError as exc:
|
|
191
|
+
raise StarkGateAPIError(f"Request failed: {exc}") from exc
|
|
192
|
+
return self._parse(response)
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _parse(response: httpx.Response) -> Any:
|
|
196
|
+
try:
|
|
197
|
+
body = response.json()
|
|
198
|
+
except ValueError:
|
|
199
|
+
body = {}
|
|
200
|
+
if not isinstance(body, dict):
|
|
201
|
+
body = {}
|
|
202
|
+
if not (200 <= response.status_code < 300):
|
|
203
|
+
raise StarkGateAPIError(
|
|
204
|
+
body.get("error") or f"HTTP {response.status_code}",
|
|
205
|
+
status_code=response.status_code,
|
|
206
|
+
)
|
|
207
|
+
if body.get("success") is False:
|
|
208
|
+
raise StarkGateAPIError(
|
|
209
|
+
body.get("error") or "Unknown API error",
|
|
210
|
+
status_code=response.status_code,
|
|
211
|
+
)
|
|
212
|
+
return body.get("data")
|
|
213
|
+
|
|
214
|
+
def evaluate(
|
|
215
|
+
self,
|
|
216
|
+
agent_id: str,
|
|
217
|
+
policy_id: str,
|
|
218
|
+
action_type: str,
|
|
219
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
220
|
+
) -> EvaluationResult:
|
|
221
|
+
return EvaluationResult.from_dict(
|
|
222
|
+
self._evaluate_raw(agent_id, policy_id, action_type, payload)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _evaluate_raw(
|
|
226
|
+
self,
|
|
227
|
+
agent_id: str,
|
|
228
|
+
policy_id: str,
|
|
229
|
+
action_type: str,
|
|
230
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
231
|
+
) -> Dict[str, Any]:
|
|
232
|
+
data = self._request(
|
|
233
|
+
"POST",
|
|
234
|
+
"/v1/evaluate",
|
|
235
|
+
json={
|
|
236
|
+
"agentId": agent_id,
|
|
237
|
+
"policyId": policy_id,
|
|
238
|
+
"actionType": action_type,
|
|
239
|
+
"payload": payload or {},
|
|
240
|
+
**self._anti_replay(),
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
if not isinstance(data, dict):
|
|
244
|
+
raise StarkGateAPIError("Unexpected response shape")
|
|
245
|
+
return data
|
|
246
|
+
|
|
247
|
+
async def async_evaluate(
|
|
248
|
+
self,
|
|
249
|
+
agent_id: str,
|
|
250
|
+
policy_id: str,
|
|
251
|
+
action_type: str,
|
|
252
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
253
|
+
) -> EvaluationResult:
|
|
254
|
+
data = await self._async_request(
|
|
255
|
+
"POST",
|
|
256
|
+
"/v1/evaluate",
|
|
257
|
+
json={
|
|
258
|
+
"agentId": agent_id,
|
|
259
|
+
"policyId": policy_id,
|
|
260
|
+
"actionType": action_type,
|
|
261
|
+
"payload": payload or {},
|
|
262
|
+
**self._anti_replay(),
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
return EvaluationResult.from_dict(data)
|
|
266
|
+
|
|
267
|
+
def dry_run(
|
|
268
|
+
self,
|
|
269
|
+
agent_id: str,
|
|
270
|
+
policy_id: str,
|
|
271
|
+
action_type: str,
|
|
272
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
273
|
+
) -> EvaluationResult:
|
|
274
|
+
"""Évalue sans bloquer : ALLOW + would_block si une règle aurait interdit l'action."""
|
|
275
|
+
data = self._request(
|
|
276
|
+
"POST",
|
|
277
|
+
"/v1/evaluate",
|
|
278
|
+
json={
|
|
279
|
+
"agentId": agent_id,
|
|
280
|
+
"policyId": policy_id,
|
|
281
|
+
"actionType": action_type,
|
|
282
|
+
"payload": payload or {},
|
|
283
|
+
"dryRun": True,
|
|
284
|
+
**self._anti_replay(),
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
return EvaluationResult.from_dict(data)
|
|
288
|
+
|
|
289
|
+
async def async_dry_run(
|
|
290
|
+
self,
|
|
291
|
+
agent_id: str,
|
|
292
|
+
policy_id: str,
|
|
293
|
+
action_type: str,
|
|
294
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
295
|
+
) -> EvaluationResult:
|
|
296
|
+
data = await self._async_request(
|
|
297
|
+
"POST",
|
|
298
|
+
"/v1/evaluate",
|
|
299
|
+
json={
|
|
300
|
+
"agentId": agent_id,
|
|
301
|
+
"policyId": policy_id,
|
|
302
|
+
"actionType": action_type,
|
|
303
|
+
"payload": payload or {},
|
|
304
|
+
"dryRun": True,
|
|
305
|
+
**self._anti_replay(),
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
return EvaluationResult.from_dict(data)
|
|
309
|
+
|
|
310
|
+
def evaluate_batch(
|
|
311
|
+
self,
|
|
312
|
+
actions: list[Dict[str, Any]],
|
|
313
|
+
) -> Dict[str, Any]:
|
|
314
|
+
"""Evaluate N actions in a single API call (≤1000).
|
|
315
|
+
|
|
316
|
+
Each action dict must contain: agentId, policyId, actionType, payload, nonce, timestamp.
|
|
317
|
+
Returns: { verdicts: [...], batch: { count, chainHash } }
|
|
318
|
+
"""
|
|
319
|
+
if len(actions) > 1000:
|
|
320
|
+
raise StarkGateAPIError("Batch size exceeds limit of 1000")
|
|
321
|
+
data = self._request(
|
|
322
|
+
"POST",
|
|
323
|
+
"/v1/evaluate/batch",
|
|
324
|
+
json={"actions": actions},
|
|
325
|
+
)
|
|
326
|
+
if not isinstance(data, dict):
|
|
327
|
+
raise StarkGateAPIError("Unexpected response shape")
|
|
328
|
+
return data
|
|
329
|
+
|
|
330
|
+
async def async_evaluate_batch(
|
|
331
|
+
self,
|
|
332
|
+
actions: list[Dict[str, Any]],
|
|
333
|
+
) -> Dict[str, Any]:
|
|
334
|
+
"""Async version of evaluate_batch."""
|
|
335
|
+
if len(actions) > 1000:
|
|
336
|
+
raise StarkGateAPIError("Batch size exceeds limit of 1000")
|
|
337
|
+
data = await self._async_request(
|
|
338
|
+
"POST",
|
|
339
|
+
"/v1/evaluate/batch",
|
|
340
|
+
json={"actions": actions},
|
|
341
|
+
)
|
|
342
|
+
return data
|
|
343
|
+
|
|
344
|
+
def protect(
|
|
345
|
+
self,
|
|
346
|
+
agent_id: str,
|
|
347
|
+
policy_id: str,
|
|
348
|
+
action_type: str,
|
|
349
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
350
|
+
strict: Optional[bool] = None,
|
|
351
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
352
|
+
effective = self.strict_mode if strict is None else strict
|
|
353
|
+
if self.strict_mode and strict is False:
|
|
354
|
+
raise StarkGateError("Bypassing the guard is forbidden in strict_mode")
|
|
355
|
+
if effective and not self.signing_key:
|
|
356
|
+
raise StarkGateError("strict_mode requires a signing_key to verify verdicts")
|
|
357
|
+
|
|
358
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
359
|
+
signature = inspect.signature(func)
|
|
360
|
+
|
|
361
|
+
@functools.wraps(func)
|
|
362
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
363
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
364
|
+
evaluation_payload: Dict[str, Any] = dict(bound.arguments)
|
|
365
|
+
if payload:
|
|
366
|
+
evaluation_payload.update(payload)
|
|
367
|
+
verdict = self._evaluate_raw(
|
|
368
|
+
agent_id=agent_id,
|
|
369
|
+
policy_id=policy_id,
|
|
370
|
+
action_type=action_type,
|
|
371
|
+
payload=evaluation_payload,
|
|
372
|
+
)
|
|
373
|
+
if effective:
|
|
374
|
+
if not verdict.get("signature"):
|
|
375
|
+
raise StarkGateError("No signed verdict returned — failing closed")
|
|
376
|
+
if not self.verify(verdict):
|
|
377
|
+
raise StarkGateError("Verdict signature invalid — failing closed")
|
|
378
|
+
result = EvaluationResult.from_dict(verdict)
|
|
379
|
+
if result.is_denied:
|
|
380
|
+
raise StarkGateDenied(
|
|
381
|
+
result.reason or "Action denied by StarkGate policy.",
|
|
382
|
+
result=result,
|
|
383
|
+
)
|
|
384
|
+
return func(*args, **kwargs)
|
|
385
|
+
|
|
386
|
+
return wrapper
|
|
387
|
+
|
|
388
|
+
return decorator
|
starkgate/ed25519.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Ed25519 (RFC 8032) — preuve vérifiable par tiers, en parallèle de HMAC.
|
|
2
|
+
|
|
3
|
+
Même corps compacté que HMAC (`canonicalize`), préfixe `ed25519:<hex>`.
|
|
4
|
+
Déterministe : même seed + même corps => même signature (TS/Python/Rust/WASM).
|
|
5
|
+
Clé privée (seed 32 octets hex) passée en paramètre, jamais stockée ici.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Any
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
11
|
+
Ed25519PrivateKey,
|
|
12
|
+
Ed25519PublicKey,
|
|
13
|
+
)
|
|
14
|
+
from cryptography.exceptions import InvalidSignature
|
|
15
|
+
|
|
16
|
+
_ED25519_PREFIX = "ed25519:"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def canonicalize(fields: Dict[str, Any]) -> str:
|
|
20
|
+
"""Canonicalisation identique à TS `canonicalizeVerdict` / Rust
|
|
21
|
+
`canonicalize_verdict` (tri des clés, F2 imbriqué via JSON déterministe)."""
|
|
22
|
+
from starkgate.client import StarkGate
|
|
23
|
+
|
|
24
|
+
return StarkGate._canonicalize(fields)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _strip_prefix(value: str) -> str:
|
|
28
|
+
return value[len(_ED25519_PREFIX) :] if value.startswith(_ED25519_PREFIX) else value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sign_ed25519(seed_hex: str, fields: Dict[str, Any]) -> str:
|
|
32
|
+
"""Signe un verdict en Ed25519. Retourne `ed25519:<hex>`."""
|
|
33
|
+
seed = bytes.fromhex(_strip_prefix(seed_hex))
|
|
34
|
+
msg = canonicalize(fields).encode("utf-8")
|
|
35
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
36
|
+
sig = sk.sign(msg)
|
|
37
|
+
return _ED25519_PREFIX + sig.hex()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ed25519_public_key(seed_hex: str) -> str:
|
|
41
|
+
"""Dérive la clé publique (32 octets hex) depuis une seed privée."""
|
|
42
|
+
seed = bytes.fromhex(_strip_prefix(seed_hex))
|
|
43
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
44
|
+
return sk.public_key().public_bytes_raw().hex()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify_ed25519(public_key_hex: str, fields: Dict[str, Any], signature: str) -> bool:
|
|
48
|
+
"""Vérifie une signature `ed25519:<hex>`. Fail-closed sur toute erreur."""
|
|
49
|
+
if not signature.startswith(_ED25519_PREFIX):
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(_strip_prefix(public_key_hex)))
|
|
53
|
+
sig = bytes.fromhex(signature[len(_ED25519_PREFIX) :])
|
|
54
|
+
msg = canonicalize(fields).encode("utf-8")
|
|
55
|
+
pub.verify(sig, msg)
|
|
56
|
+
return True
|
|
57
|
+
except (InvalidSignature, ValueError, TypeError):
|
|
58
|
+
return False
|