tinyplace 0.1.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.
- tinyplace/__init__.py +71 -0
- tinyplace/api/__init__.py +17 -0
- tinyplace/api/directory.py +44 -0
- tinyplace/api/docs.py +69 -0
- tinyplace/api/keys.py +32 -0
- tinyplace/api/messages.py +32 -0
- tinyplace/api/payments.py +145 -0
- tinyplace/api/registry.py +180 -0
- tinyplace/api/search.py +45 -0
- tinyplace/auth.py +93 -0
- tinyplace/client.py +52 -0
- tinyplace/crypto.py +62 -0
- tinyplace/http.py +273 -0
- tinyplace/signer.py +51 -0
- tinyplace/solana.py +175 -0
- tinyplace/types.py +8 -0
- tinyplace/x402.py +117 -0
- tinyplace-0.1.0.dist-info/METADATA +70 -0
- tinyplace-0.1.0.dist-info/RECORD +20 -0
- tinyplace-0.1.0.dist-info/WHEEL +4 -0
tinyplace/auth.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from .crypto import base64_url, sha256_hex
|
|
9
|
+
from .signer import Signer
|
|
10
|
+
from .types import Headers
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AdminSigningOptions:
|
|
15
|
+
actor: str | None = None
|
|
16
|
+
role: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def timestamp() -> str:
|
|
20
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_nonce() -> str:
|
|
24
|
+
return base64.b64encode(secrets.token_bytes(16)).decode("ascii")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _to_base64(data: bytes) -> str:
|
|
28
|
+
return base64.b64encode(data).decode("ascii")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_auth_header(agent_id: str, signature: str, signed_at: str) -> Headers:
|
|
32
|
+
return {"Authorization": f"tiny.place {agent_id}:{signature}:{signed_at}"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def sign_request(signer: Signer, body: str) -> Headers:
|
|
36
|
+
signed_at = timestamp()
|
|
37
|
+
signature = await signer.sign(f"{body}{signed_at}".encode("utf-8"))
|
|
38
|
+
return build_auth_header(signer.agent_id, _to_base64(signature), signed_at)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def sign_admin_request(
|
|
42
|
+
signer: Signer,
|
|
43
|
+
method: str,
|
|
44
|
+
request_uri: str,
|
|
45
|
+
body: str,
|
|
46
|
+
options: AdminSigningOptions | None = None,
|
|
47
|
+
) -> Headers:
|
|
48
|
+
options = options or AdminSigningOptions()
|
|
49
|
+
signed_at = timestamp()
|
|
50
|
+
nonce = generate_nonce()
|
|
51
|
+
actor = options.actor or signer.agent_id
|
|
52
|
+
role_line = f"\n{options.role}" if options.role else ""
|
|
53
|
+
payload = f"{method}\n{request_uri}\n{signed_at}\n{nonce}\n{sha256_hex(body)}{role_line}"
|
|
54
|
+
signature = await signer.sign(payload.encode("utf-8"))
|
|
55
|
+
role_field = f',role="{options.role}"' if options.role else ""
|
|
56
|
+
return {
|
|
57
|
+
"Authorization": (
|
|
58
|
+
f'TinyPlace-Admin actor="{actor}"{role_field},'
|
|
59
|
+
f'signature="{_to_base64(signature)}"'
|
|
60
|
+
),
|
|
61
|
+
"X-TinyPlace-Date": signed_at,
|
|
62
|
+
"X-TinyPlace-Nonce": nonce,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def sign_directory_write(
|
|
67
|
+
signer: Signer,
|
|
68
|
+
public_key_base64: str,
|
|
69
|
+
method: str,
|
|
70
|
+
request_uri: str,
|
|
71
|
+
body: str,
|
|
72
|
+
) -> Headers:
|
|
73
|
+
signed_at = timestamp()
|
|
74
|
+
nonce = generate_nonce()
|
|
75
|
+
payload = f"{method}\n{request_uri}\n{signed_at}\n{nonce}\n{sha256_hex(body)}"
|
|
76
|
+
signature = await signer.sign(payload.encode("utf-8"))
|
|
77
|
+
return {
|
|
78
|
+
"X-TinyPlace-Date": signed_at,
|
|
79
|
+
"X-TinyPlace-Nonce": nonce,
|
|
80
|
+
"X-TinyPlace-Public-Key": public_key_base64,
|
|
81
|
+
"X-TinyPlace-Signature": _to_base64(signature),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def sign_canonical_payload(signer: Signer, payload: str) -> str:
|
|
86
|
+
return _to_base64(await signer.sign(payload.encode("utf-8")))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def sign_fresh_canonical_payload(signer: Signer, payload: str) -> str:
|
|
90
|
+
signed_at = timestamp()
|
|
91
|
+
nonce = generate_nonce()
|
|
92
|
+
signature = await signer.sign(f"{payload}\n{signed_at}\n{nonce}".encode("utf-8"))
|
|
93
|
+
return f"v1:{base64_url(signed_at)}:{base64_url(nonce)}:{_to_base64(signature)}"
|
tinyplace/client.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
|
|
5
|
+
from .api import DirectoryApi, DocsApi, KeysApi, MessagesApi, PaymentsApi, RegistryApi, SearchApi
|
|
6
|
+
from .auth import AdminSigningOptions
|
|
7
|
+
from .http import AuthInvalidHook, HttpClient
|
|
8
|
+
from .signer import Signer
|
|
9
|
+
from .types import Json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TinyPlaceClient:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
base_url: str,
|
|
17
|
+
signer: Signer | None = None,
|
|
18
|
+
admin_signer: Signer | None = None,
|
|
19
|
+
admin: AdminSigningOptions | None = None,
|
|
20
|
+
session: aiohttp.ClientSession | None = None,
|
|
21
|
+
on_auth_invalid: AuthInvalidHook | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.http = HttpClient(
|
|
24
|
+
base_url=base_url,
|
|
25
|
+
signer=signer,
|
|
26
|
+
admin_signer=admin_signer,
|
|
27
|
+
admin=admin,
|
|
28
|
+
session=session,
|
|
29
|
+
on_auth_invalid=on_auth_invalid,
|
|
30
|
+
)
|
|
31
|
+
self.registry = RegistryApi(self.http, signer)
|
|
32
|
+
self.keys = KeysApi(self.http)
|
|
33
|
+
self.messages = MessagesApi(self.http)
|
|
34
|
+
self.directory = DirectoryApi(self.http)
|
|
35
|
+
self.payments = PaymentsApi(self.http, signer)
|
|
36
|
+
self.search = SearchApi(self.http)
|
|
37
|
+
self.docs = DocsApi(self.http)
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> "TinyPlaceClient":
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
43
|
+
await self.close()
|
|
44
|
+
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
await self.http.close()
|
|
47
|
+
|
|
48
|
+
async def healthz(self) -> Json:
|
|
49
|
+
return await self.http.get("/healthz")
|
|
50
|
+
|
|
51
|
+
async def spec(self) -> Json:
|
|
52
|
+
return await self.http.get("/spec")
|
tinyplace/crypto.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def public_key_to_base64(public_key: bytes) -> str:
|
|
12
|
+
return base64.b64encode(public_key).decode("ascii")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def public_key_to_solana_address(public_key: bytes) -> str:
|
|
16
|
+
value = int.from_bytes(public_key, "big")
|
|
17
|
+
encoded = ""
|
|
18
|
+
while value > 0:
|
|
19
|
+
value, digit = divmod(value, 58)
|
|
20
|
+
encoded = BASE58_ALPHABET[digit] + encoded
|
|
21
|
+
|
|
22
|
+
leading_zeroes = 0
|
|
23
|
+
for byte in public_key:
|
|
24
|
+
if byte != 0:
|
|
25
|
+
break
|
|
26
|
+
leading_zeroes += 1
|
|
27
|
+
return ("1" * leading_zeroes) + encoded if encoded else "1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def derive_crypto_id(public_key: bytes) -> str:
|
|
31
|
+
return public_key_to_solana_address(public_key)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sha256_hex(data: bytes | str) -> str:
|
|
35
|
+
if isinstance(data, str):
|
|
36
|
+
data = data.encode("utf-8")
|
|
37
|
+
return hashlib.sha256(data).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def canonical_payload(action: str, fields: dict[str, Any]) -> str:
|
|
41
|
+
return _stable_json({"action": action, "fields": fields})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _stable_json(value: Any) -> str:
|
|
45
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def base64_url(value: str) -> str:
|
|
49
|
+
return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decode_base58(value: str) -> bytes:
|
|
53
|
+
decoded = 0
|
|
54
|
+
for char in value:
|
|
55
|
+
digit = BASE58_ALPHABET.find(char)
|
|
56
|
+
if digit == -1:
|
|
57
|
+
raise ValueError(f"Invalid base58 character: {char}")
|
|
58
|
+
decoded = decoded * 58 + digit
|
|
59
|
+
|
|
60
|
+
raw = decoded.to_bytes((decoded.bit_length() + 7) // 8, "big") if decoded else b""
|
|
61
|
+
leading_zeroes = len(value) - len(value.lstrip("1"))
|
|
62
|
+
return (b"\x00" * leading_zeroes) + raw
|
tinyplace/http.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Awaitable, Callable
|
|
6
|
+
from urllib.parse import quote, urlencode
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from .auth import AdminSigningOptions, sign_admin_request, sign_directory_write, sign_request
|
|
11
|
+
from .signer import Signer
|
|
12
|
+
from .types import Headers, Json, Query
|
|
13
|
+
|
|
14
|
+
AuthInvalidHook = Callable[[int, Json], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class PaymentChallenge:
|
|
19
|
+
scheme: str | None = None
|
|
20
|
+
network: str | None = None
|
|
21
|
+
asset: str | None = None
|
|
22
|
+
amount: str | None = None
|
|
23
|
+
from_: str | None = None
|
|
24
|
+
to: str | None = None
|
|
25
|
+
nonce: str | None = None
|
|
26
|
+
expires_at: str | None = None
|
|
27
|
+
signature: str | None = None
|
|
28
|
+
metadata: dict[str, str] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PaymentRequiredChallenge:
|
|
33
|
+
payment: dict[str, Any]
|
|
34
|
+
error: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TinyPlaceError(Exception):
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
status: int,
|
|
41
|
+
body: Json,
|
|
42
|
+
message: str | None = None,
|
|
43
|
+
*,
|
|
44
|
+
headers: Headers | None = None,
|
|
45
|
+
payment_required: PaymentRequiredChallenge | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(message or f"HTTP {status}")
|
|
48
|
+
self.status = status
|
|
49
|
+
self.body = body
|
|
50
|
+
self.headers = headers or {}
|
|
51
|
+
self.payment_required = payment_required or _payment_required_from_body(body)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HttpClient:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
base_url: str,
|
|
59
|
+
signer: Signer | None = None,
|
|
60
|
+
admin_signer: Signer | None = None,
|
|
61
|
+
admin: AdminSigningOptions | None = None,
|
|
62
|
+
session: aiohttp.ClientSession | None = None,
|
|
63
|
+
on_auth_invalid: AuthInvalidHook | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.base_url = base_url.rstrip("/")
|
|
66
|
+
self.signer = signer
|
|
67
|
+
self.public_key_base64 = signer.public_key_base64 if signer else None
|
|
68
|
+
self.admin_signer = admin_signer
|
|
69
|
+
self.admin = admin or AdminSigningOptions()
|
|
70
|
+
self._session = session
|
|
71
|
+
self._owns_session = session is None
|
|
72
|
+
self._on_auth_invalid = on_auth_invalid
|
|
73
|
+
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
if self._session and self._owns_session:
|
|
76
|
+
await self._session.close()
|
|
77
|
+
self._session = None
|
|
78
|
+
|
|
79
|
+
def signing_public_key(self) -> str | None:
|
|
80
|
+
return self.public_key_base64
|
|
81
|
+
|
|
82
|
+
async def get(self, path: str, query: Query = None) -> Json:
|
|
83
|
+
return await self._request("GET", path, query=query)
|
|
84
|
+
|
|
85
|
+
async def get_auth(self, path: str, query: Query = None) -> Json:
|
|
86
|
+
return await self._request("GET", path, query=query, auth="signed")
|
|
87
|
+
|
|
88
|
+
async def get_admin(self, path: str, query: Query = None) -> Json:
|
|
89
|
+
return await self._request("GET", path, query=query, auth="admin")
|
|
90
|
+
|
|
91
|
+
async def get_text(self, path: str, query: Query = None) -> str:
|
|
92
|
+
return await self._request("GET", path, query=query, response_type="text")
|
|
93
|
+
|
|
94
|
+
async def get_directory_auth(self, path: str, query: Query = None) -> Json:
|
|
95
|
+
return await self._request("GET", path, query=query, auth="directory")
|
|
96
|
+
|
|
97
|
+
async def get_directory_auth_as(self, path: str, actor: str, query: Query = None) -> Json:
|
|
98
|
+
return await self._request("GET", path, query=query, auth="directory", actor=actor)
|
|
99
|
+
|
|
100
|
+
async def get_agent_auth(self, path: str, query: Query = None) -> Json:
|
|
101
|
+
return await self._request("GET", path, query=query, auth="agent")
|
|
102
|
+
|
|
103
|
+
async def post(self, path: str, body: Json = None) -> Json:
|
|
104
|
+
return await self._request("POST", path, body=body, auth="signed")
|
|
105
|
+
|
|
106
|
+
async def post_public(self, path: str, body: Json = None) -> Json:
|
|
107
|
+
return await self._request("POST", path, body=body)
|
|
108
|
+
|
|
109
|
+
async def post_admin(self, path: str, body: Json = None) -> Json:
|
|
110
|
+
return await self._request("POST", path, body=body, auth="admin")
|
|
111
|
+
|
|
112
|
+
async def post_directory_auth(self, path: str, body: Json = None) -> Json:
|
|
113
|
+
return await self._request("POST", path, body=body, auth="directory")
|
|
114
|
+
|
|
115
|
+
async def post_directory_auth_as(self, path: str, actor: str, body: Json = None) -> Json:
|
|
116
|
+
return await self._request("POST", path, body=body, auth="directory", actor=actor)
|
|
117
|
+
|
|
118
|
+
async def put(self, path: str, body: Json = None) -> Json:
|
|
119
|
+
return await self._request("PUT", path, body=body, auth="signed")
|
|
120
|
+
|
|
121
|
+
async def put_directory_auth(self, path: str, body: Json = None) -> Json:
|
|
122
|
+
return await self._request("PUT", path, body=body, auth="directory")
|
|
123
|
+
|
|
124
|
+
async def put_directory_auth_as(self, path: str, actor: str, body: Json = None) -> Json:
|
|
125
|
+
return await self._request("PUT", path, body=body, auth="directory", actor=actor)
|
|
126
|
+
|
|
127
|
+
async def put_agent_auth(self, path: str, body: Json = None) -> Json:
|
|
128
|
+
return await self._request("PUT", path, body=body, auth="agent")
|
|
129
|
+
|
|
130
|
+
async def delete(self, path: str, body: Json = None) -> Json:
|
|
131
|
+
return await self._request("DELETE", path, body=body, auth="signed")
|
|
132
|
+
|
|
133
|
+
async def delete_public(
|
|
134
|
+
self,
|
|
135
|
+
path: str,
|
|
136
|
+
body: Json = None,
|
|
137
|
+
headers: Headers | None = None,
|
|
138
|
+
) -> Json:
|
|
139
|
+
return await self._request("DELETE", path, body=body, headers=headers)
|
|
140
|
+
|
|
141
|
+
async def delete_directory_auth(self, path: str, body: Json = None) -> Json:
|
|
142
|
+
return await self._request("DELETE", path, body=body, auth="directory")
|
|
143
|
+
|
|
144
|
+
async def delete_directory_auth_as(self, path: str, actor: str, body: Json = None) -> Json:
|
|
145
|
+
return await self._request("DELETE", path, body=body, auth="directory", actor=actor)
|
|
146
|
+
|
|
147
|
+
async def delete_agent_auth(self, path: str, body: Json = None) -> Json:
|
|
148
|
+
return await self._request("DELETE", path, body=body, auth="agent")
|
|
149
|
+
|
|
150
|
+
async def _request(
|
|
151
|
+
self,
|
|
152
|
+
method: str,
|
|
153
|
+
path: str,
|
|
154
|
+
*,
|
|
155
|
+
query: Query = None,
|
|
156
|
+
body: Json = None,
|
|
157
|
+
auth: str | None = None,
|
|
158
|
+
actor: str | None = None,
|
|
159
|
+
headers: Headers | None = None,
|
|
160
|
+
response_type: str = "json",
|
|
161
|
+
) -> Json:
|
|
162
|
+
query_string = _build_query(query)
|
|
163
|
+
request_uri = f"{path}{query_string}"
|
|
164
|
+
url = f"{self.base_url}{request_uri}"
|
|
165
|
+
body_text = "" if body is None else json.dumps(body, separators=(",", ":"))
|
|
166
|
+
request_headers = {"Content-Type": "application/json", **(headers or {})}
|
|
167
|
+
|
|
168
|
+
await self._apply_auth(request_headers, auth, method, request_uri, body_text, actor)
|
|
169
|
+
session = self._get_session()
|
|
170
|
+
response = await session.request(
|
|
171
|
+
method,
|
|
172
|
+
url,
|
|
173
|
+
headers=request_headers,
|
|
174
|
+
data=body_text or None,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if response.status < 200 or response.status >= 300:
|
|
178
|
+
await self._raise_error(path, response)
|
|
179
|
+
|
|
180
|
+
if response.status == 204:
|
|
181
|
+
return None
|
|
182
|
+
if response_type == "raw":
|
|
183
|
+
return response
|
|
184
|
+
if response_type == "text":
|
|
185
|
+
return await response.text()
|
|
186
|
+
text = await response.text()
|
|
187
|
+
return None if text == "" else json.loads(text)
|
|
188
|
+
|
|
189
|
+
async def _apply_auth(
|
|
190
|
+
self,
|
|
191
|
+
headers: Headers,
|
|
192
|
+
auth: str | None,
|
|
193
|
+
method: str,
|
|
194
|
+
request_uri: str,
|
|
195
|
+
body: str,
|
|
196
|
+
actor: str | None,
|
|
197
|
+
) -> None:
|
|
198
|
+
if auth == "admin" and self.admin_signer:
|
|
199
|
+
headers.update(
|
|
200
|
+
await sign_admin_request(self.admin_signer, method, request_uri, body, self.admin)
|
|
201
|
+
)
|
|
202
|
+
elif auth in ("directory", "agent") and self.signer and self.public_key_base64:
|
|
203
|
+
headers.update(
|
|
204
|
+
await sign_directory_write(
|
|
205
|
+
self.signer,
|
|
206
|
+
self.public_key_base64,
|
|
207
|
+
method,
|
|
208
|
+
request_uri,
|
|
209
|
+
body,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
headers["X-Agent-ID"] = self.signer.agent_id if auth == "agent" else actor or self.public_key_base64
|
|
213
|
+
elif auth == "signed" and self.signer:
|
|
214
|
+
headers.update(await sign_request(self.signer, body))
|
|
215
|
+
|
|
216
|
+
async def _raise_error(self, path: str, response: Any) -> None:
|
|
217
|
+
text = await response.text()
|
|
218
|
+
try:
|
|
219
|
+
body = json.loads(text)
|
|
220
|
+
except json.JSONDecodeError:
|
|
221
|
+
body = text
|
|
222
|
+
headers = {str(k): str(v) for k, v in getattr(response, "headers", {}).items()}
|
|
223
|
+
if response.status in (401, 403) and self._on_auth_invalid:
|
|
224
|
+
self._on_auth_invalid(response.status, body)
|
|
225
|
+
raise TinyPlaceError(
|
|
226
|
+
response.status,
|
|
227
|
+
body,
|
|
228
|
+
f"HTTP {response.status}: {path}",
|
|
229
|
+
headers=headers,
|
|
230
|
+
payment_required=_payment_required_from_header(headers),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _get_session(self) -> Any:
|
|
234
|
+
if self._session is None:
|
|
235
|
+
self._session = aiohttp.ClientSession()
|
|
236
|
+
return self._session
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def encode(value: str) -> str:
|
|
240
|
+
return quote(value, safe="")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _build_query(query: Query) -> str:
|
|
244
|
+
if not query:
|
|
245
|
+
return ""
|
|
246
|
+
pairs: list[tuple[str, str]] = []
|
|
247
|
+
for key, value in query.items():
|
|
248
|
+
if value is None:
|
|
249
|
+
continue
|
|
250
|
+
if isinstance(value, list):
|
|
251
|
+
pairs.extend((key, str(item)) for item in value)
|
|
252
|
+
else:
|
|
253
|
+
pairs.append((key, str(value)))
|
|
254
|
+
return f"?{urlencode(pairs)}" if pairs else ""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _payment_required_from_body(body: Json) -> PaymentRequiredChallenge | None:
|
|
258
|
+
if isinstance(body, dict) and isinstance(body.get("payment"), dict):
|
|
259
|
+
return PaymentRequiredChallenge(error=body.get("error"), payment=body["payment"])
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _payment_required_from_header(headers: Headers) -> PaymentRequiredChallenge | None:
|
|
264
|
+
value = headers.get("X-Payment-Required") or headers.get("x-payment-required")
|
|
265
|
+
if not value:
|
|
266
|
+
return None
|
|
267
|
+
try:
|
|
268
|
+
parsed = json.loads(value)
|
|
269
|
+
except json.JSONDecodeError:
|
|
270
|
+
return None
|
|
271
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("payment"), dict):
|
|
272
|
+
return PaymentRequiredChallenge(error=parsed.get("error"), payment=parsed["payment"])
|
|
273
|
+
return None
|
tinyplace/signer.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from nacl.signing import SigningKey
|
|
6
|
+
|
|
7
|
+
from .crypto import decode_base58, derive_crypto_id, public_key_to_base64
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Signer(ABC):
|
|
11
|
+
"""Abstract signing strategy for agent, directory, and admin auth."""
|
|
12
|
+
|
|
13
|
+
agent_id: str
|
|
14
|
+
public_key_base64: str
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def sign(self, data: bytes) -> bytes:
|
|
18
|
+
"""Return an Ed25519 signature over ``data``."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LocalSigner(Signer):
|
|
22
|
+
"""Local Ed25519 signer backed by PyNaCl."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, signing_key: SigningKey) -> None:
|
|
25
|
+
self._signing_key = signing_key
|
|
26
|
+
self.public_key = bytes(signing_key.verify_key)
|
|
27
|
+
self.agent_id = derive_crypto_id(self.public_key)
|
|
28
|
+
self.public_key_base64 = public_key_to_base64(self.public_key)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def generate(cls) -> "LocalSigner":
|
|
32
|
+
return cls(SigningKey.generate())
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_seed(cls, seed: bytes) -> "LocalSigner":
|
|
36
|
+
if len(seed) != 32:
|
|
37
|
+
raise ValueError(f"Ed25519 seed must be 32 bytes, got {len(seed)}")
|
|
38
|
+
return cls(SigningKey(seed))
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_solana_secret_key(cls, secret_key: str | bytes) -> "LocalSigner":
|
|
42
|
+
secret = decode_base58(secret_key) if isinstance(secret_key, str) else secret_key
|
|
43
|
+
if len(secret) not in (32, 64):
|
|
44
|
+
raise ValueError(f"Solana secret key must be 32 or 64 bytes, got {len(secret)}")
|
|
45
|
+
signer = cls.from_seed(secret[:32])
|
|
46
|
+
if len(secret) == 64 and signer.public_key != secret[32:]:
|
|
47
|
+
raise ValueError("Solana secret key public key does not match seed")
|
|
48
|
+
return signer
|
|
49
|
+
|
|
50
|
+
async def sign(self, data: bytes) -> bytes:
|
|
51
|
+
return bytes(self._signing_key.sign(data).signature)
|