aex-sdk 1.2.0a1__tar.gz
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-1.2.0a1/PKG-INFO +68 -0
- aex_sdk-1.2.0a1/README.md +42 -0
- aex_sdk-1.2.0a1/pyproject.toml +48 -0
- aex_sdk-1.2.0a1/setup.cfg +4 -0
- aex_sdk-1.2.0a1/src/aex_sdk/__init__.py +16 -0
- aex_sdk-1.2.0a1/src/aex_sdk/client.py +333 -0
- aex_sdk-1.2.0a1/src/aex_sdk/errors.py +19 -0
- aex_sdk-1.2.0a1/src/aex_sdk/identity.py +173 -0
- aex_sdk-1.2.0a1/src/aex_sdk/py.typed +0 -0
- aex_sdk-1.2.0a1/src/aex_sdk/wire.py +106 -0
- aex_sdk-1.2.0a1/src/aex_sdk.egg-info/PKG-INFO +68 -0
- aex_sdk-1.2.0a1/src/aex_sdk.egg-info/SOURCES.txt +16 -0
- aex_sdk-1.2.0a1/src/aex_sdk.egg-info/dependency_links.txt +1 -0
- aex_sdk-1.2.0a1/src/aex_sdk.egg-info/requires.txt +8 -0
- aex_sdk-1.2.0a1/src/aex_sdk.egg-info/top_level.txt +1 -0
- aex_sdk-1.2.0a1/tests/test_client_end_to_end.py +111 -0
- aex_sdk-1.2.0a1/tests/test_identity.py +87 -0
- aex_sdk-1.2.0a1/tests/test_wire.py +111 -0
aex_sdk-1.2.0a1/PKG-INFO
ADDED
|
@@ -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,42 @@
|
|
|
1
|
+
# spize (Python SDK)
|
|
2
|
+
|
|
3
|
+
Python client for the [Agent Exchange Protocol (AEX)](https://github.com/icaroholding/spize).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install spize
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from spize import Identity, SpizeClient
|
|
15
|
+
|
|
16
|
+
# One-time: create + register an identity.
|
|
17
|
+
identity = Identity.generate(org="acme", name="alice")
|
|
18
|
+
identity.save("alice.key")
|
|
19
|
+
|
|
20
|
+
client = SpizeClient(base_url="http://localhost:8080", identity=identity)
|
|
21
|
+
client.register()
|
|
22
|
+
|
|
23
|
+
# Send.
|
|
24
|
+
transfer = client.send(
|
|
25
|
+
recipient="spize:acme/bob:aabbcc",
|
|
26
|
+
file="invoice.pdf",
|
|
27
|
+
declared_mime="application/pdf",
|
|
28
|
+
)
|
|
29
|
+
print(transfer.state) # 'ready_for_pickup' or 'rejected'
|
|
30
|
+
|
|
31
|
+
# Receive (as Bob).
|
|
32
|
+
bob = Identity.load("bob.key")
|
|
33
|
+
bob_client = SpizeClient(base_url="http://localhost:8080", identity=bob)
|
|
34
|
+
bytes_in = bob_client.download(transfer.transfer_id)
|
|
35
|
+
bob_client.ack(transfer.transfer_id)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Components
|
|
39
|
+
|
|
40
|
+
- `Identity` — Ed25519 keypair + canonical agent_id derivation. Save/load to disk.
|
|
41
|
+
- `SpizeClient` — thin HTTP wrapper over the control plane. Handles signing + replay nonces.
|
|
42
|
+
- `wire` — canonical byte functions that mirror `spize_core::wire` exactly; change only in lockstep.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aex-sdk"
|
|
7
|
+
version = "1.2.0a1"
|
|
8
|
+
description = "Python SDK for AEX — the Agent Exchange Protocol."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Icaro Holding", email = "oss@spize.ai" }]
|
|
13
|
+
keywords = ["aex", "agent", "protocol", "identity", "transfer", "p2p"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
"cryptography>=42",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"pytest-asyncio>=0.23",
|
|
31
|
+
"ruff>=0.3",
|
|
32
|
+
"mypy>=1.8",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://spize.ai"
|
|
37
|
+
Repository = "https://github.com/icaroholding/spize"
|
|
38
|
+
Documentation = "https://github.com/icaroholding/spize/tree/master/docs"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.package-data]
|
|
44
|
+
aex_sdk = ["py.typed"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
asyncio_mode = "auto"
|
|
@@ -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"
|
|
@@ -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
|
+
)
|
|
@@ -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."""
|
|
@@ -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
|
|
File without changes
|
|
@@ -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,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/aex_sdk/__init__.py
|
|
4
|
+
src/aex_sdk/client.py
|
|
5
|
+
src/aex_sdk/errors.py
|
|
6
|
+
src/aex_sdk/identity.py
|
|
7
|
+
src/aex_sdk/py.typed
|
|
8
|
+
src/aex_sdk/wire.py
|
|
9
|
+
src/aex_sdk.egg-info/PKG-INFO
|
|
10
|
+
src/aex_sdk.egg-info/SOURCES.txt
|
|
11
|
+
src/aex_sdk.egg-info/dependency_links.txt
|
|
12
|
+
src/aex_sdk.egg-info/requires.txt
|
|
13
|
+
src/aex_sdk.egg-info/top_level.txt
|
|
14
|
+
tests/test_client_end_to_end.py
|
|
15
|
+
tests/test_identity.py
|
|
16
|
+
tests/test_wire.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aex_sdk
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""End-to-end integration test for the Python SDK.
|
|
2
|
+
|
|
3
|
+
Requires a running control plane (started automatically via a subprocess
|
|
4
|
+
fixture if not already up). Marked optional because it spins up services
|
|
5
|
+
and runs real HTTP — skip it in CI where Postgres isn't available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from aex_sdk import Identity, SpizeClient
|
|
19
|
+
from aex_sdk.errors import SpizeHTTPError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _server_reachable(url: str) -> bool:
|
|
23
|
+
"""Quick TCP check against base url host:port."""
|
|
24
|
+
import urllib.parse as up
|
|
25
|
+
|
|
26
|
+
parsed = up.urlparse(url)
|
|
27
|
+
host = parsed.hostname or "127.0.0.1"
|
|
28
|
+
port = parsed.port or 80
|
|
29
|
+
try:
|
|
30
|
+
with socket.create_connection((host, port), timeout=0.25):
|
|
31
|
+
return True
|
|
32
|
+
except OSError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BASE_URL = os.environ.get("SPIZE_TEST_BASE_URL", "http://127.0.0.1:8080")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.skipif(
|
|
40
|
+
not _server_reachable(BASE_URL),
|
|
41
|
+
reason=f"control plane at {BASE_URL} not reachable; run it manually",
|
|
42
|
+
)
|
|
43
|
+
def test_alice_sends_clean_file_to_bob(tmp_path) -> None:
|
|
44
|
+
alice = Identity.generate(
|
|
45
|
+
org="sdktest",
|
|
46
|
+
name=f"alice{int(time.time())}",
|
|
47
|
+
)
|
|
48
|
+
bob = Identity.generate(
|
|
49
|
+
org="sdktest",
|
|
50
|
+
name=f"bob{int(time.time())}",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
with SpizeClient(BASE_URL, alice) as alice_client, SpizeClient(
|
|
54
|
+
BASE_URL, bob
|
|
55
|
+
) as bob_client:
|
|
56
|
+
alice_client.register()
|
|
57
|
+
bob_client.register()
|
|
58
|
+
|
|
59
|
+
payload = b"e2e-test-" + os.urandom(32)
|
|
60
|
+
tx = alice_client.send(
|
|
61
|
+
recipient=bob.agent_id,
|
|
62
|
+
data=payload,
|
|
63
|
+
declared_mime="text/plain",
|
|
64
|
+
filename="note.txt",
|
|
65
|
+
)
|
|
66
|
+
assert tx.state == "ready_for_pickup", f"unexpected state: {tx.state}"
|
|
67
|
+
|
|
68
|
+
received = bob_client.download(tx.transfer_id)
|
|
69
|
+
assert received == payload
|
|
70
|
+
|
|
71
|
+
ack = bob_client.ack(tx.transfer_id)
|
|
72
|
+
assert ack["state"] == "delivered"
|
|
73
|
+
assert len(ack["audit_chain_head"]) == 64
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.skipif(
|
|
77
|
+
not _server_reachable(BASE_URL),
|
|
78
|
+
reason="control plane not reachable",
|
|
79
|
+
)
|
|
80
|
+
def test_eicar_blocked() -> None:
|
|
81
|
+
alice = Identity.generate(
|
|
82
|
+
org="sdktest",
|
|
83
|
+
name=f"eicarsend{int(time.time())}",
|
|
84
|
+
)
|
|
85
|
+
bob = Identity.generate(
|
|
86
|
+
org="sdktest",
|
|
87
|
+
name=f"eicarrecv{int(time.time())}",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
with SpizeClient(BASE_URL, alice) as alice_client, SpizeClient(
|
|
91
|
+
BASE_URL, bob
|
|
92
|
+
) as bob_client:
|
|
93
|
+
alice_client.register()
|
|
94
|
+
bob_client.register()
|
|
95
|
+
|
|
96
|
+
eicar = (
|
|
97
|
+
b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
|
|
98
|
+
)
|
|
99
|
+
tx = alice_client.send(
|
|
100
|
+
recipient=bob.agent_id,
|
|
101
|
+
data=eicar,
|
|
102
|
+
declared_mime="text/plain",
|
|
103
|
+
filename="test.txt",
|
|
104
|
+
)
|
|
105
|
+
assert tx.was_rejected, f"EICAR should be rejected; state = {tx.state}"
|
|
106
|
+
assert tx.rejection_code == "scanner_malicious"
|
|
107
|
+
|
|
108
|
+
# Bob can't download a rejected transfer.
|
|
109
|
+
with pytest.raises(SpizeHTTPError) as ei:
|
|
110
|
+
bob_client.download(tx.transfer_id)
|
|
111
|
+
assert ei.value.status_code == 404
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from aex_sdk import Identity
|
|
9
|
+
from aex_sdk.errors import IdentityError
|
|
10
|
+
from aex_sdk.identity import verify_signature
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_generate_produces_valid_agent_id(tmp_path) -> None:
|
|
14
|
+
ident = Identity.generate(org="acme", name="alice")
|
|
15
|
+
assert ident.agent_id.startswith("spize:acme/alice:")
|
|
16
|
+
assert len(ident.fingerprint) == 6
|
|
17
|
+
assert all(c in "0123456789abcdef" for c in ident.fingerprint)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_deterministic_from_secret() -> None:
|
|
21
|
+
secret = b"\x07" * 32
|
|
22
|
+
a = Identity.from_secret("acme", "alice", secret)
|
|
23
|
+
b = Identity.from_secret("acme", "alice", secret)
|
|
24
|
+
assert a.agent_id == b.agent_id
|
|
25
|
+
assert a.public_key_bytes == b.public_key_bytes
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_sign_and_verify_roundtrip() -> None:
|
|
29
|
+
ident = Identity.generate(org="acme", name="alice")
|
|
30
|
+
sig = ident.sign(b"hello")
|
|
31
|
+
assert verify_signature(ident.public_key_bytes, b"hello", sig)
|
|
32
|
+
assert not verify_signature(ident.public_key_bytes, b"hxllo", sig)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_save_load_roundtrip(tmp_path) -> None:
|
|
36
|
+
ident = Identity.generate(org="acme", name="alice")
|
|
37
|
+
path = tmp_path / "alice.key"
|
|
38
|
+
ident.save(path)
|
|
39
|
+
loaded = Identity.load(path)
|
|
40
|
+
assert loaded.agent_id == ident.agent_id
|
|
41
|
+
assert loaded.public_key_bytes == ident.public_key_bytes
|
|
42
|
+
assert loaded.private_key_bytes == ident.private_key_bytes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_save_refuses_overwrite(tmp_path) -> None:
|
|
46
|
+
ident = Identity.generate(org="acme", name="alice")
|
|
47
|
+
path = tmp_path / "alice.key"
|
|
48
|
+
ident.save(path)
|
|
49
|
+
with pytest.raises(IdentityError):
|
|
50
|
+
ident.save(path)
|
|
51
|
+
# With overwrite=True it succeeds.
|
|
52
|
+
ident.save(path, overwrite=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_saved_file_has_0600_perms(tmp_path) -> None:
|
|
56
|
+
ident = Identity.generate(org="acme", name="alice")
|
|
57
|
+
path = tmp_path / "alice.key"
|
|
58
|
+
ident.save(path)
|
|
59
|
+
mode = stat.S_IMODE(os.stat(path).st_mode)
|
|
60
|
+
assert mode == 0o600, f"expected 0600 perms, got {oct(mode)}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_tampered_file_rejected(tmp_path) -> None:
|
|
64
|
+
ident = Identity.generate(org="acme", name="alice")
|
|
65
|
+
path = tmp_path / "alice.key"
|
|
66
|
+
ident.save(path)
|
|
67
|
+
# Corrupt the stored public_key_hex so it mismatches the private key.
|
|
68
|
+
content = path.read_text()
|
|
69
|
+
content = content.replace(ident.public_key_hex, "00" * 32)
|
|
70
|
+
path.write_text(content)
|
|
71
|
+
with pytest.raises(IdentityError, match="public_key_hex"):
|
|
72
|
+
Identity.load(path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_bad_org_rejected() -> None:
|
|
76
|
+
with pytest.raises(IdentityError):
|
|
77
|
+
Identity.generate(org="acme corp", name="alice")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_empty_name_rejected() -> None:
|
|
81
|
+
with pytest.raises(IdentityError):
|
|
82
|
+
Identity.generate(org="acme", name="")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_bad_secret_length_rejected() -> None:
|
|
86
|
+
with pytest.raises(IdentityError):
|
|
87
|
+
Identity.from_secret("acme", "alice", b"short")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Canonical wire bytes must be byte-identical to the Rust side.
|
|
2
|
+
|
|
3
|
+
The expected byte strings here are the same golden vectors used in
|
|
4
|
+
`crates/spize-core/src/wire.rs` tests. If you modify either side, update
|
|
5
|
+
both in the same commit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from aex_sdk import wire
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_registration_challenge_stable_bytes() -> None:
|
|
16
|
+
bytes_ = wire.registration_challenge_bytes(
|
|
17
|
+
public_key_hex="aabbcc",
|
|
18
|
+
org="acme",
|
|
19
|
+
name="alice",
|
|
20
|
+
nonce="0123456789abcdef0123456789abcdef",
|
|
21
|
+
issued_at_unix=1_700_000_000,
|
|
22
|
+
)
|
|
23
|
+
expected = (
|
|
24
|
+
b"spize-register:v1\n"
|
|
25
|
+
b"pub=aabbcc\n"
|
|
26
|
+
b"org=acme\n"
|
|
27
|
+
b"name=alice\n"
|
|
28
|
+
b"nonce=0123456789abcdef0123456789abcdef\n"
|
|
29
|
+
b"ts=1700000000"
|
|
30
|
+
)
|
|
31
|
+
assert bytes_ == expected
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_transfer_intent_stable_bytes() -> None:
|
|
35
|
+
bytes_ = wire.transfer_intent_bytes(
|
|
36
|
+
sender_agent_id="spize:acme/alice:aabbcc",
|
|
37
|
+
recipient="spize:acme/bob:ddeeff",
|
|
38
|
+
size_bytes=12345,
|
|
39
|
+
declared_mime="application/pdf",
|
|
40
|
+
filename="invoice.pdf",
|
|
41
|
+
nonce="0123456789abcdef0123456789abcdef",
|
|
42
|
+
issued_at_unix=1_700_000_000,
|
|
43
|
+
)
|
|
44
|
+
expected = (
|
|
45
|
+
b"spize-transfer-intent:v1\n"
|
|
46
|
+
b"sender=spize:acme/alice:aabbcc\n"
|
|
47
|
+
b"recipient=spize:acme/bob:ddeeff\n"
|
|
48
|
+
b"size=12345\n"
|
|
49
|
+
b"mime=application/pdf\n"
|
|
50
|
+
b"filename=invoice.pdf\n"
|
|
51
|
+
b"nonce=0123456789abcdef0123456789abcdef\n"
|
|
52
|
+
b"ts=1700000000"
|
|
53
|
+
)
|
|
54
|
+
assert bytes_ == expected
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_transfer_intent_empty_optionals() -> None:
|
|
58
|
+
bytes_ = wire.transfer_intent_bytes(
|
|
59
|
+
sender_agent_id="spize:acme/alice:aabbcc",
|
|
60
|
+
recipient="bob@example.com",
|
|
61
|
+
size_bytes=100,
|
|
62
|
+
declared_mime="",
|
|
63
|
+
filename="",
|
|
64
|
+
nonce="0123456789abcdef0123456789abcdef",
|
|
65
|
+
issued_at_unix=1_700_000_000,
|
|
66
|
+
)
|
|
67
|
+
s = bytes_.decode()
|
|
68
|
+
assert "mime=\n" in s
|
|
69
|
+
assert "filename=\n" in s
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_transfer_receipt_stable_bytes() -> None:
|
|
73
|
+
bytes_ = wire.transfer_receipt_bytes(
|
|
74
|
+
recipient_agent_id="spize:acme/bob:ddeeff",
|
|
75
|
+
transfer_id="tx_abc123",
|
|
76
|
+
action="ack",
|
|
77
|
+
nonce="0123456789abcdef0123456789abcdef",
|
|
78
|
+
issued_at_unix=1_700_000_000,
|
|
79
|
+
)
|
|
80
|
+
expected = (
|
|
81
|
+
b"spize-transfer-receipt:v1\n"
|
|
82
|
+
b"recipient=spize:acme/bob:ddeeff\n"
|
|
83
|
+
b"transfer=tx_abc123\n"
|
|
84
|
+
b"action=ack\n"
|
|
85
|
+
b"nonce=0123456789abcdef0123456789abcdef\n"
|
|
86
|
+
b"ts=1700000000"
|
|
87
|
+
)
|
|
88
|
+
assert bytes_ == expected
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_invalid_action_rejected() -> None:
|
|
92
|
+
with pytest.raises(ValueError):
|
|
93
|
+
wire.transfer_receipt_bytes(
|
|
94
|
+
"spize:acme/bob:ddeeff", "tx_abc", "overwrite",
|
|
95
|
+
"0123456789abcdef0123456789abcdef", 1,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_short_nonce_rejected() -> None:
|
|
100
|
+
with pytest.raises(ValueError):
|
|
101
|
+
wire.registration_challenge_bytes(
|
|
102
|
+
"aa", "acme", "alice", "deadbeef", 100,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_newline_in_field_rejected() -> None:
|
|
107
|
+
with pytest.raises(ValueError):
|
|
108
|
+
wire.registration_challenge_bytes(
|
|
109
|
+
"aa", "ac\nme", "alice",
|
|
110
|
+
"0123456789abcdef0123456789abcdef", 100,
|
|
111
|
+
)
|