aex-sdk 1.2.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.
- aex_sdk/__init__.py +16 -0
- aex_sdk/client.py +333 -0
- aex_sdk/errors.py +19 -0
- aex_sdk/identity.py +173 -0
- aex_sdk/py.typed +0 -0
- aex_sdk/wire.py +106 -0
- aex_sdk-1.2.0a1.dist-info/METADATA +68 -0
- aex_sdk-1.2.0a1.dist-info/RECORD +10 -0
- aex_sdk-1.2.0a1.dist-info/WHEEL +5 -0
- aex_sdk-1.2.0a1.dist-info/top_level.txt +1 -0
aex_sdk/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Agent Exchange Protocol (AEX) — Python SDK."""
|
|
2
|
+
|
|
3
|
+
from aex_sdk.client import DataPlaneTicket, SpizeClient, TransferResponse
|
|
4
|
+
from aex_sdk.errors import SpizeError, SpizeHTTPError
|
|
5
|
+
from aex_sdk.identity import Identity
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DataPlaneTicket",
|
|
9
|
+
"Identity",
|
|
10
|
+
"SpizeClient",
|
|
11
|
+
"SpizeError",
|
|
12
|
+
"SpizeHTTPError",
|
|
13
|
+
"TransferResponse",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "1.2.0a1"
|
aex_sdk/client.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Synchronous HTTP client for the Spize control plane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from aex_sdk.errors import SpizeError, SpizeHTTPError
|
|
14
|
+
from aex_sdk.identity import Identity, random_nonce
|
|
15
|
+
from aex_sdk.wire import (
|
|
16
|
+
registration_challenge_bytes,
|
|
17
|
+
transfer_intent_bytes,
|
|
18
|
+
transfer_receipt_bytes,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TransferResponse:
|
|
24
|
+
transfer_id: str
|
|
25
|
+
state: str
|
|
26
|
+
sender_agent_id: str
|
|
27
|
+
recipient: str
|
|
28
|
+
size_bytes: int
|
|
29
|
+
declared_mime: Optional[str]
|
|
30
|
+
filename: Optional[str]
|
|
31
|
+
scanner_verdict: Optional[dict[str, Any]]
|
|
32
|
+
policy_decision: Optional[dict[str, Any]]
|
|
33
|
+
rejection_code: Optional[str]
|
|
34
|
+
rejection_reason: Optional[str]
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_json(cls, body: dict[str, Any]) -> "TransferResponse":
|
|
38
|
+
return cls(
|
|
39
|
+
transfer_id=body["transfer_id"],
|
|
40
|
+
state=body["state"],
|
|
41
|
+
sender_agent_id=body["sender_agent_id"],
|
|
42
|
+
recipient=body["recipient"],
|
|
43
|
+
size_bytes=int(body["size_bytes"]),
|
|
44
|
+
declared_mime=body.get("declared_mime"),
|
|
45
|
+
filename=body.get("filename"),
|
|
46
|
+
scanner_verdict=body.get("scanner_verdict"),
|
|
47
|
+
policy_decision=body.get("policy_decision"),
|
|
48
|
+
rejection_code=body.get("rejection_code"),
|
|
49
|
+
rejection_reason=body.get("rejection_reason"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def was_delivered(self) -> bool:
|
|
54
|
+
return self.state == "delivered"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def was_rejected(self) -> bool:
|
|
58
|
+
return self.state == "rejected"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SpizeClient:
|
|
62
|
+
"""Thin wrapper over the control-plane REST API.
|
|
63
|
+
|
|
64
|
+
The client is stateless beyond `base_url` + `identity`; each call
|
|
65
|
+
builds a fresh nonce and signs the canonical payload. Reuses an
|
|
66
|
+
httpx.Client for connection pooling.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
base_url: str,
|
|
72
|
+
identity: Identity,
|
|
73
|
+
*,
|
|
74
|
+
timeout: float = 30.0,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.base_url = base_url.rstrip("/")
|
|
77
|
+
self.identity = identity
|
|
78
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout)
|
|
79
|
+
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
self._http.close()
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> "SpizeClient":
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(self, *exc) -> None: # noqa: D401 - context manager boilerplate
|
|
87
|
+
self.close()
|
|
88
|
+
|
|
89
|
+
# ------------------------------ health ------------------------------
|
|
90
|
+
|
|
91
|
+
def health(self) -> dict[str, Any]:
|
|
92
|
+
r = self._http.get("/healthz")
|
|
93
|
+
self._raise_for_status(r)
|
|
94
|
+
return r.json()
|
|
95
|
+
|
|
96
|
+
# ---------------------------- registration ---------------------------
|
|
97
|
+
|
|
98
|
+
def register(self) -> dict[str, Any]:
|
|
99
|
+
"""Register this identity with the control plane (idempotent-safe:
|
|
100
|
+
re-registering the same public key returns 409 Conflict — callers
|
|
101
|
+
can treat that as 'already registered')."""
|
|
102
|
+
issued_at = int(time.time())
|
|
103
|
+
nonce = random_nonce()
|
|
104
|
+
challenge = registration_challenge_bytes(
|
|
105
|
+
self.identity.public_key_hex,
|
|
106
|
+
self.identity.org,
|
|
107
|
+
self.identity.name,
|
|
108
|
+
nonce,
|
|
109
|
+
issued_at,
|
|
110
|
+
)
|
|
111
|
+
signature = self.identity.sign(challenge)
|
|
112
|
+
payload = {
|
|
113
|
+
"public_key_hex": self.identity.public_key_hex,
|
|
114
|
+
"org": self.identity.org,
|
|
115
|
+
"name": self.identity.name,
|
|
116
|
+
"nonce": nonce,
|
|
117
|
+
"issued_at": issued_at,
|
|
118
|
+
"signature_hex": signature.hex(),
|
|
119
|
+
}
|
|
120
|
+
r = self._http.post("/v1/agents/register", json=payload)
|
|
121
|
+
self._raise_for_status(r)
|
|
122
|
+
return r.json()
|
|
123
|
+
|
|
124
|
+
def get_agent(self, agent_id: str) -> dict[str, Any]:
|
|
125
|
+
r = self._http.get(f"/v1/agents/{agent_id}")
|
|
126
|
+
self._raise_for_status(r)
|
|
127
|
+
return r.json()
|
|
128
|
+
|
|
129
|
+
# ------------------------------- send -------------------------------
|
|
130
|
+
|
|
131
|
+
def send(
|
|
132
|
+
self,
|
|
133
|
+
recipient: str,
|
|
134
|
+
*,
|
|
135
|
+
data: Optional[bytes] = None,
|
|
136
|
+
file: Optional[str | Path] = None,
|
|
137
|
+
declared_mime: str = "",
|
|
138
|
+
filename: str = "",
|
|
139
|
+
) -> TransferResponse:
|
|
140
|
+
"""Initiate a transfer. Provide exactly one of `data` or `file`."""
|
|
141
|
+
if (data is None) == (file is None):
|
|
142
|
+
raise SpizeError("pass exactly one of data= or file=")
|
|
143
|
+
if file is not None:
|
|
144
|
+
p = Path(file)
|
|
145
|
+
data = p.read_bytes()
|
|
146
|
+
if not filename:
|
|
147
|
+
filename = p.name
|
|
148
|
+
assert data is not None
|
|
149
|
+
|
|
150
|
+
issued_at = int(time.time())
|
|
151
|
+
nonce = random_nonce()
|
|
152
|
+
canonical = transfer_intent_bytes(
|
|
153
|
+
self.identity.agent_id,
|
|
154
|
+
recipient,
|
|
155
|
+
len(data),
|
|
156
|
+
declared_mime,
|
|
157
|
+
filename,
|
|
158
|
+
nonce,
|
|
159
|
+
issued_at,
|
|
160
|
+
)
|
|
161
|
+
signature = self.identity.sign(canonical)
|
|
162
|
+
payload = {
|
|
163
|
+
"sender_agent_id": self.identity.agent_id,
|
|
164
|
+
"recipient": recipient,
|
|
165
|
+
"declared_mime": declared_mime,
|
|
166
|
+
"filename": filename,
|
|
167
|
+
"nonce": nonce,
|
|
168
|
+
"issued_at": issued_at,
|
|
169
|
+
"intent_signature_hex": signature.hex(),
|
|
170
|
+
"blob_hex": data.hex(),
|
|
171
|
+
}
|
|
172
|
+
r = self._http.post("/v1/transfers", json=payload)
|
|
173
|
+
self._raise_for_status(r)
|
|
174
|
+
return TransferResponse.from_json(r.json())
|
|
175
|
+
|
|
176
|
+
# ----------------------------- receive ------------------------------
|
|
177
|
+
|
|
178
|
+
def send_via_tunnel(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
recipient: str,
|
|
182
|
+
declared_size: int,
|
|
183
|
+
declared_mime: str,
|
|
184
|
+
filename: str,
|
|
185
|
+
tunnel_url: str,
|
|
186
|
+
) -> TransferResponse:
|
|
187
|
+
"""M2: announce a transfer without uploading bytes. The sender
|
|
188
|
+
must serve the blob via `tunnel_url` (a data-plane URL)."""
|
|
189
|
+
if not self.identity:
|
|
190
|
+
raise ValueError("client has no identity")
|
|
191
|
+
nonce = random_nonce()
|
|
192
|
+
issued_at = int(time.time())
|
|
193
|
+
intent = transfer_intent_bytes(
|
|
194
|
+
sender_agent_id=self.identity.agent_id,
|
|
195
|
+
recipient=recipient,
|
|
196
|
+
size_bytes=declared_size,
|
|
197
|
+
declared_mime=declared_mime,
|
|
198
|
+
filename=filename,
|
|
199
|
+
nonce=nonce,
|
|
200
|
+
issued_at_unix=issued_at,
|
|
201
|
+
)
|
|
202
|
+
sig = self.identity.sign(intent)
|
|
203
|
+
payload = {
|
|
204
|
+
"sender_agent_id": self.identity.agent_id,
|
|
205
|
+
"recipient": recipient,
|
|
206
|
+
"declared_mime": declared_mime,
|
|
207
|
+
"filename": filename,
|
|
208
|
+
"nonce": nonce,
|
|
209
|
+
"issued_at": issued_at,
|
|
210
|
+
"intent_signature_hex": sig.hex(),
|
|
211
|
+
"blob_hex": "",
|
|
212
|
+
"tunnel_url": tunnel_url,
|
|
213
|
+
"declared_size": declared_size,
|
|
214
|
+
}
|
|
215
|
+
r = self._http.post("/v1/transfers", json=payload)
|
|
216
|
+
self._raise_for_status(r)
|
|
217
|
+
return TransferResponse.from_json(r.json())
|
|
218
|
+
|
|
219
|
+
def get_transfer(self, transfer_id: str) -> TransferResponse:
|
|
220
|
+
r = self._http.get(f"/v1/transfers/{transfer_id}")
|
|
221
|
+
self._raise_for_status(r)
|
|
222
|
+
return TransferResponse.from_json(r.json())
|
|
223
|
+
|
|
224
|
+
def download(self, transfer_id: str) -> bytes:
|
|
225
|
+
"""Download the blob bytes. Must be called by the declared
|
|
226
|
+
recipient (signature bound to the recipient's identity)."""
|
|
227
|
+
body = self._build_receipt(transfer_id, "download")
|
|
228
|
+
r = self._http.post(f"/v1/transfers/{transfer_id}/download", json=body)
|
|
229
|
+
self._raise_for_status(r)
|
|
230
|
+
return bytes.fromhex(r.json()["blob_hex"])
|
|
231
|
+
|
|
232
|
+
def ack(self, transfer_id: str) -> dict[str, Any]:
|
|
233
|
+
"""Acknowledge delivery. The returned `audit_chain_head` is proof
|
|
234
|
+
the delivery was logged at this chain position."""
|
|
235
|
+
body = self._build_receipt(transfer_id, "ack")
|
|
236
|
+
r = self._http.post(f"/v1/transfers/{transfer_id}/ack", json=body)
|
|
237
|
+
self._raise_for_status(r)
|
|
238
|
+
return r.json()
|
|
239
|
+
|
|
240
|
+
def request_ticket(self, transfer_id: str) -> "DataPlaneTicket":
|
|
241
|
+
"""M2: request a signed data-plane ticket to fetch the blob directly
|
|
242
|
+
from the sender's tunnel, bypassing the control plane for payload.
|
|
243
|
+
Requires the transfer to be in ``ready_for_pickup`` with a tunnel_url.
|
|
244
|
+
"""
|
|
245
|
+
receipt = self._build_receipt(transfer_id, "request_ticket")
|
|
246
|
+
r = self._http.post(
|
|
247
|
+
f"/v1/transfers/{transfer_id}/ticket",
|
|
248
|
+
json=receipt,
|
|
249
|
+
)
|
|
250
|
+
self._raise_for_status(r)
|
|
251
|
+
body = r.json()
|
|
252
|
+
return DataPlaneTicket(
|
|
253
|
+
transfer_id=body["transfer_id"],
|
|
254
|
+
recipient=body["recipient"],
|
|
255
|
+
data_plane_url=body["data_plane_url"],
|
|
256
|
+
expires=int(body["expires"]),
|
|
257
|
+
nonce=body["nonce"],
|
|
258
|
+
signature=body["signature"],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def fetch_from_tunnel(self, ticket: "DataPlaneTicket") -> bytes:
|
|
262
|
+
"""M2: fetch blob bytes from the sender's data plane using a ticket."""
|
|
263
|
+
r = httpx.get(
|
|
264
|
+
f"{ticket.data_plane_url}/blob/{ticket.transfer_id}",
|
|
265
|
+
headers={"X-AEX-Ticket": ticket.as_header()},
|
|
266
|
+
timeout=30.0,
|
|
267
|
+
)
|
|
268
|
+
self._raise_for_status(r)
|
|
269
|
+
return r.content
|
|
270
|
+
|
|
271
|
+
def inbox(self) -> dict[str, Any]:
|
|
272
|
+
"""List transfers waiting for this identity (state:
|
|
273
|
+
`ready_for_pickup` or `accepted`). Capped at 100 most recent rows."""
|
|
274
|
+
body = self._build_receipt("inbox", "inbox")
|
|
275
|
+
r = self._http.post("/v1/inbox", json=body)
|
|
276
|
+
self._raise_for_status(r)
|
|
277
|
+
return r.json()
|
|
278
|
+
|
|
279
|
+
def _build_receipt(self, transfer_id: str, action: str) -> dict[str, Any]:
|
|
280
|
+
issued_at = int(time.time())
|
|
281
|
+
nonce = random_nonce()
|
|
282
|
+
canonical = transfer_receipt_bytes(
|
|
283
|
+
self.identity.agent_id, transfer_id, action, nonce, issued_at
|
|
284
|
+
)
|
|
285
|
+
signature = self.identity.sign(canonical)
|
|
286
|
+
return {
|
|
287
|
+
"recipient_agent_id": self.identity.agent_id,
|
|
288
|
+
"nonce": nonce,
|
|
289
|
+
"issued_at": issued_at,
|
|
290
|
+
"signature_hex": signature.hex(),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
# ------------------------------ helpers ------------------------------
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _raise_for_status(r: httpx.Response) -> None:
|
|
297
|
+
if r.is_success:
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
body = r.json()
|
|
301
|
+
except Exception:
|
|
302
|
+
body = {}
|
|
303
|
+
raise SpizeHTTPError(
|
|
304
|
+
status_code=r.status_code,
|
|
305
|
+
code=body.get("code"),
|
|
306
|
+
message=body.get("message") or r.text or "unknown error",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------- M2 additions ----------
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class DataPlaneTicket:
|
|
314
|
+
transfer_id: str
|
|
315
|
+
recipient: str
|
|
316
|
+
data_plane_url: str
|
|
317
|
+
expires: int
|
|
318
|
+
nonce: str
|
|
319
|
+
signature: str
|
|
320
|
+
|
|
321
|
+
def as_header(self) -> str:
|
|
322
|
+
"""JSON-encoded ticket for the `X-AEX-Ticket` header."""
|
|
323
|
+
return json.dumps(
|
|
324
|
+
{
|
|
325
|
+
"transfer_id": self.transfer_id,
|
|
326
|
+
"recipient": self.recipient,
|
|
327
|
+
"data_plane_url": self.data_plane_url,
|
|
328
|
+
"expires": self.expires,
|
|
329
|
+
"nonce": self.nonce,
|
|
330
|
+
"signature": self.signature,
|
|
331
|
+
},
|
|
332
|
+
separators=(",", ":"),
|
|
333
|
+
)
|
aex_sdk/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Spize SDK exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SpizeError(Exception):
|
|
5
|
+
"""Root class for SDK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpizeHTTPError(SpizeError):
|
|
9
|
+
"""Raised when the control plane returns a non-2xx response."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status_code: int, code: str | None, message: str) -> None:
|
|
12
|
+
super().__init__(f"[{status_code}] {code or 'error'}: {message}")
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
self.code = code
|
|
15
|
+
self.message = message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IdentityError(SpizeError):
|
|
19
|
+
"""Raised for identity-file corruption or mismatched keys."""
|
aex_sdk/identity.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Spize-native Ed25519 identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import string
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey,
|
|
17
|
+
Ed25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from aex_sdk.errors import IdentityError
|
|
21
|
+
|
|
22
|
+
_LABEL_ALPHABET = set(string.ascii_letters + string.digits + "-_")
|
|
23
|
+
_LABEL_MAX = 64
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _validate_label(s: str, field: str) -> None:
|
|
27
|
+
if not s:
|
|
28
|
+
raise IdentityError(f"{field} is empty")
|
|
29
|
+
if len(s) > _LABEL_MAX:
|
|
30
|
+
raise IdentityError(f"{field} exceeds {_LABEL_MAX} chars")
|
|
31
|
+
for c in s:
|
|
32
|
+
if c not in _LABEL_ALPHABET:
|
|
33
|
+
raise IdentityError(
|
|
34
|
+
f"{field} must match [a-zA-Z0-9_-]+, got {c!r}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _compute_fingerprint(public_key_bytes: bytes) -> str:
|
|
39
|
+
"""First 3 bytes of SHA-256 over the public key, hex-encoded."""
|
|
40
|
+
return hashlib.sha256(public_key_bytes).digest()[:3].hex()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Identity:
|
|
45
|
+
"""Ed25519 keypair + canonical Spize agent_id."""
|
|
46
|
+
|
|
47
|
+
org: str
|
|
48
|
+
name: str
|
|
49
|
+
private_key_bytes: bytes # 32 bytes
|
|
50
|
+
public_key_bytes: bytes # 32 bytes
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def generate(cls, org: str, name: str) -> "Identity":
|
|
54
|
+
_validate_label(org, "org")
|
|
55
|
+
_validate_label(name, "name")
|
|
56
|
+
private_key = Ed25519PrivateKey.generate()
|
|
57
|
+
private_bytes = private_key.private_bytes(
|
|
58
|
+
encoding=serialization.Encoding.Raw,
|
|
59
|
+
format=serialization.PrivateFormat.Raw,
|
|
60
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
61
|
+
)
|
|
62
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
63
|
+
encoding=serialization.Encoding.Raw,
|
|
64
|
+
format=serialization.PublicFormat.Raw,
|
|
65
|
+
)
|
|
66
|
+
return cls(org=org, name=name, private_key_bytes=private_bytes, public_key_bytes=public_bytes)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_secret(cls, org: str, name: str, private_key_bytes: bytes) -> "Identity":
|
|
70
|
+
_validate_label(org, "org")
|
|
71
|
+
_validate_label(name, "name")
|
|
72
|
+
if len(private_key_bytes) != 32:
|
|
73
|
+
raise IdentityError(f"Ed25519 secret must be 32 bytes, got {len(private_key_bytes)}")
|
|
74
|
+
private_key = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
|
|
75
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
76
|
+
encoding=serialization.Encoding.Raw,
|
|
77
|
+
format=serialization.PublicFormat.Raw,
|
|
78
|
+
)
|
|
79
|
+
return cls(org=org, name=name, private_key_bytes=private_key_bytes, public_key_bytes=public_bytes)
|
|
80
|
+
|
|
81
|
+
# ---------- derived properties ----------
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def fingerprint(self) -> str:
|
|
85
|
+
return _compute_fingerprint(self.public_key_bytes)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def agent_id(self) -> str:
|
|
89
|
+
return f"spize:{self.org}/{self.name}:{self.fingerprint}"
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def public_key_hex(self) -> str:
|
|
93
|
+
return self.public_key_bytes.hex()
|
|
94
|
+
|
|
95
|
+
# ---------- signing ----------
|
|
96
|
+
|
|
97
|
+
def sign(self, message: bytes) -> bytes:
|
|
98
|
+
return Ed25519PrivateKey.from_private_bytes(self.private_key_bytes).sign(message)
|
|
99
|
+
|
|
100
|
+
# ---------- persistence ----------
|
|
101
|
+
|
|
102
|
+
def save(self, path: str | os.PathLike, *, overwrite: bool = False) -> None:
|
|
103
|
+
"""Persist the identity to a JSON file with 0600 perms.
|
|
104
|
+
|
|
105
|
+
Write pattern: write to a sibling tmp file → fsync → rename. This
|
|
106
|
+
guarantees the final path either contains the full, valid JSON or
|
|
107
|
+
nothing at all — a crash during save cannot leave a truncated key
|
|
108
|
+
file that re-opens as corrupt.
|
|
109
|
+
"""
|
|
110
|
+
p = Path(path)
|
|
111
|
+
if p.exists() and not overwrite:
|
|
112
|
+
raise IdentityError(f"{p} already exists; pass overwrite=True to replace")
|
|
113
|
+
payload = {
|
|
114
|
+
"version": 1,
|
|
115
|
+
"org": self.org,
|
|
116
|
+
"name": self.name,
|
|
117
|
+
"private_key_hex": self.private_key_bytes.hex(),
|
|
118
|
+
"public_key_hex": self.public_key_bytes.hex(),
|
|
119
|
+
"agent_id": self.agent_id,
|
|
120
|
+
}
|
|
121
|
+
data = json.dumps(payload, indent=2).encode("utf-8")
|
|
122
|
+
|
|
123
|
+
tmp = p.with_name(p.name + ".tmp")
|
|
124
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
125
|
+
fd = os.open(tmp, flags, 0o600)
|
|
126
|
+
try:
|
|
127
|
+
with os.fdopen(fd, "wb") as f:
|
|
128
|
+
f.write(data)
|
|
129
|
+
f.flush()
|
|
130
|
+
os.fsync(f.fileno())
|
|
131
|
+
os.replace(tmp, p)
|
|
132
|
+
except Exception:
|
|
133
|
+
try:
|
|
134
|
+
tmp.unlink(missing_ok=True)
|
|
135
|
+
except OSError:
|
|
136
|
+
pass
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def load(cls, path: str | os.PathLike) -> "Identity":
|
|
141
|
+
p = Path(path)
|
|
142
|
+
with open(p, "rb") as f:
|
|
143
|
+
payload = json.loads(f.read().decode("utf-8"))
|
|
144
|
+
if payload.get("version") != 1:
|
|
145
|
+
raise IdentityError(f"unsupported identity file version: {payload.get('version')}")
|
|
146
|
+
try:
|
|
147
|
+
org = payload["org"]
|
|
148
|
+
name = payload["name"]
|
|
149
|
+
private_key_hex = payload["private_key_hex"]
|
|
150
|
+
except KeyError as e:
|
|
151
|
+
raise IdentityError(f"missing field in identity file: {e.args[0]}") from e
|
|
152
|
+
|
|
153
|
+
identity = cls.from_secret(org, name, bytes.fromhex(private_key_hex))
|
|
154
|
+
# Sanity: stored public/agent_id should match derived values.
|
|
155
|
+
if "public_key_hex" in payload and payload["public_key_hex"] != identity.public_key_hex:
|
|
156
|
+
raise IdentityError("stored public_key_hex does not match derived public key")
|
|
157
|
+
if "agent_id" in payload and payload["agent_id"] != identity.agent_id:
|
|
158
|
+
raise IdentityError("stored agent_id does not match derived agent_id")
|
|
159
|
+
return identity
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def random_nonce(byte_length: int = 16) -> str:
|
|
163
|
+
"""Hex nonce with `byte_length` bytes of entropy."""
|
|
164
|
+
return secrets.token_hex(byte_length)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def verify_signature(public_key_bytes: bytes, message: bytes, signature: bytes) -> bool:
|
|
168
|
+
"""Verify an Ed25519 signature; returns True/False without raising."""
|
|
169
|
+
try:
|
|
170
|
+
Ed25519PublicKey.from_public_bytes(public_key_bytes).verify(signature, message)
|
|
171
|
+
return True
|
|
172
|
+
except Exception:
|
|
173
|
+
return False
|
aex_sdk/py.typed
ADDED
|
File without changes
|
aex_sdk/wire.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Canonical wire-format functions.
|
|
2
|
+
|
|
3
|
+
These MUST produce byte-for-byte identical output to the corresponding
|
|
4
|
+
Rust functions in ``aex_core::wire``. The test suite in
|
|
5
|
+
``tests/test_wire.py`` checks this against the golden vectors exported
|
|
6
|
+
from the Rust tests — DO NOT modify without updating both sides
|
|
7
|
+
together.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
PROTOCOL_VERSION = "v1"
|
|
13
|
+
MAX_CLOCK_SKEW_SECS = 300
|
|
14
|
+
MIN_NONCE_LEN = 32
|
|
15
|
+
MAX_NONCE_LEN = 128
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _validate_ascii_line(s: str, field: str, *, allow_empty: bool = False) -> None:
|
|
19
|
+
if not s:
|
|
20
|
+
if allow_empty:
|
|
21
|
+
return
|
|
22
|
+
raise ValueError(f"{field} is empty")
|
|
23
|
+
for i, c in enumerate(s):
|
|
24
|
+
if ord(c) > 127 or c in ("\n", "\r", "\0"):
|
|
25
|
+
raise ValueError(f"{field} has invalid char at {i}: {c!r}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_nonce(nonce: str) -> None:
|
|
29
|
+
if not (MIN_NONCE_LEN <= len(nonce) <= MAX_NONCE_LEN):
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"nonce length {len(nonce)} outside [{MIN_NONCE_LEN}, {MAX_NONCE_LEN}]"
|
|
32
|
+
)
|
|
33
|
+
if not all(c in "0123456789abcdefABCDEF" for c in nonce):
|
|
34
|
+
raise ValueError("nonce must be hex")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def registration_challenge_bytes(
|
|
38
|
+
public_key_hex: str,
|
|
39
|
+
org: str,
|
|
40
|
+
name: str,
|
|
41
|
+
nonce: str,
|
|
42
|
+
issued_at_unix: int,
|
|
43
|
+
) -> bytes:
|
|
44
|
+
_validate_ascii_line(public_key_hex, "public_key_hex")
|
|
45
|
+
_validate_ascii_line(org, "org")
|
|
46
|
+
_validate_ascii_line(name, "name")
|
|
47
|
+
_validate_nonce(nonce)
|
|
48
|
+
return (
|
|
49
|
+
f"spize-register:{PROTOCOL_VERSION}\n"
|
|
50
|
+
f"pub={public_key_hex}\n"
|
|
51
|
+
f"org={org}\n"
|
|
52
|
+
f"name={name}\n"
|
|
53
|
+
f"nonce={nonce}\n"
|
|
54
|
+
f"ts={issued_at_unix}"
|
|
55
|
+
).encode("ascii")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def transfer_intent_bytes(
|
|
59
|
+
sender_agent_id: str,
|
|
60
|
+
recipient: str,
|
|
61
|
+
size_bytes: int,
|
|
62
|
+
declared_mime: str,
|
|
63
|
+
filename: str,
|
|
64
|
+
nonce: str,
|
|
65
|
+
issued_at_unix: int,
|
|
66
|
+
) -> bytes:
|
|
67
|
+
_validate_ascii_line(sender_agent_id, "sender_agent_id")
|
|
68
|
+
_validate_ascii_line(recipient, "recipient")
|
|
69
|
+
_validate_ascii_line(declared_mime, "declared_mime", allow_empty=True)
|
|
70
|
+
_validate_ascii_line(filename, "filename", allow_empty=True)
|
|
71
|
+
_validate_nonce(nonce)
|
|
72
|
+
return (
|
|
73
|
+
f"spize-transfer-intent:{PROTOCOL_VERSION}\n"
|
|
74
|
+
f"sender={sender_agent_id}\n"
|
|
75
|
+
f"recipient={recipient}\n"
|
|
76
|
+
f"size={size_bytes}\n"
|
|
77
|
+
f"mime={declared_mime}\n"
|
|
78
|
+
f"filename={filename}\n"
|
|
79
|
+
f"nonce={nonce}\n"
|
|
80
|
+
f"ts={issued_at_unix}"
|
|
81
|
+
).encode("ascii")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def transfer_receipt_bytes(
|
|
85
|
+
recipient_agent_id: str,
|
|
86
|
+
transfer_id: str,
|
|
87
|
+
action: str,
|
|
88
|
+
nonce: str,
|
|
89
|
+
issued_at_unix: int,
|
|
90
|
+
) -> bytes:
|
|
91
|
+
_validate_ascii_line(recipient_agent_id, "recipient_agent_id")
|
|
92
|
+
_validate_ascii_line(transfer_id, "transfer_id")
|
|
93
|
+
_validate_ascii_line(action, "action")
|
|
94
|
+
_validate_nonce(nonce)
|
|
95
|
+
if action not in ("download", "ack", "inbox", "request_ticket"):
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"action must be 'download', 'ack', 'inbox' or 'request_ticket', got {action}"
|
|
98
|
+
)
|
|
99
|
+
return (
|
|
100
|
+
f"spize-transfer-receipt:{PROTOCOL_VERSION}\n"
|
|
101
|
+
f"recipient={recipient_agent_id}\n"
|
|
102
|
+
f"transfer={transfer_id}\n"
|
|
103
|
+
f"action={action}\n"
|
|
104
|
+
f"nonce={nonce}\n"
|
|
105
|
+
f"ts={issued_at_unix}"
|
|
106
|
+
).encode("ascii")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aex-sdk
|
|
3
|
+
Version: 1.2.0a1
|
|
4
|
+
Summary: Python SDK for AEX — the Agent Exchange Protocol.
|
|
5
|
+
Author-email: Icaro Holding <oss@spize.ai>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://spize.ai
|
|
8
|
+
Project-URL: Repository, https://github.com/icaroholding/spize
|
|
9
|
+
Project-URL: Documentation, https://github.com/icaroholding/spize/tree/master/docs
|
|
10
|
+
Keywords: aex,agent,protocol,identity,transfer,p2p
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: cryptography>=42
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff>=0.3; extra == "dev"
|
|
25
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# spize (Python SDK)
|
|
28
|
+
|
|
29
|
+
Python client for the [Agent Exchange Protocol (AEX)](https://github.com/icaroholding/spize).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install spize
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from spize import Identity, SpizeClient
|
|
41
|
+
|
|
42
|
+
# One-time: create + register an identity.
|
|
43
|
+
identity = Identity.generate(org="acme", name="alice")
|
|
44
|
+
identity.save("alice.key")
|
|
45
|
+
|
|
46
|
+
client = SpizeClient(base_url="http://localhost:8080", identity=identity)
|
|
47
|
+
client.register()
|
|
48
|
+
|
|
49
|
+
# Send.
|
|
50
|
+
transfer = client.send(
|
|
51
|
+
recipient="spize:acme/bob:aabbcc",
|
|
52
|
+
file="invoice.pdf",
|
|
53
|
+
declared_mime="application/pdf",
|
|
54
|
+
)
|
|
55
|
+
print(transfer.state) # 'ready_for_pickup' or 'rejected'
|
|
56
|
+
|
|
57
|
+
# Receive (as Bob).
|
|
58
|
+
bob = Identity.load("bob.key")
|
|
59
|
+
bob_client = SpizeClient(base_url="http://localhost:8080", identity=bob)
|
|
60
|
+
bytes_in = bob_client.download(transfer.transfer_id)
|
|
61
|
+
bob_client.ack(transfer.transfer_id)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Components
|
|
65
|
+
|
|
66
|
+
- `Identity` — Ed25519 keypair + canonical agent_id derivation. Save/load to disk.
|
|
67
|
+
- `SpizeClient` — thin HTTP wrapper over the control plane. Handles signing + replay nonces.
|
|
68
|
+
- `wire` — canonical byte functions that mirror `spize_core::wire` exactly; change only in lockstep.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
aex_sdk/__init__.py,sha256=eW0QJckAFRD-KEZvbVbaET8qyHEv9ikyQ97Wti1vUbA,381
|
|
2
|
+
aex_sdk/client.py,sha256=gaTbCGqIHFHNcYDwaGJdfwtQr7D5ShXf-l_rmFGg28A,11126
|
|
3
|
+
aex_sdk/errors.py,sha256=j6Is0kXzcvYjeEpmPmXaZW3LpxkcOMvgDSMGQjW6ZYk,562
|
|
4
|
+
aex_sdk/identity.py,sha256=C-gdh-Q0SaDWeKzHsjRaix_mvl_OEhJHoPCXDYhEEG8,6210
|
|
5
|
+
aex_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
aex_sdk/wire.py,sha256=9llhKZfHQ-UViUJYGGqYWH1PapkZkgxLh3qMJr-N6qs,3222
|
|
7
|
+
aex_sdk-1.2.0a1.dist-info/METADATA,sha256=n5eLqb5nnEkFiVm5TQKa758iu1XCFtuBdwFkoPqfA7w,2195
|
|
8
|
+
aex_sdk-1.2.0a1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
aex_sdk-1.2.0a1.dist-info/top_level.txt,sha256=c45JG6I1zKEI6nO9YuYfT3XTG82BzFfRT9m0M35vEhk,8
|
|
10
|
+
aex_sdk-1.2.0a1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aex_sdk
|