aisot 0.1.0__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.
- aisot-0.1.0/PKG-INFO +52 -0
- aisot-0.1.0/README.md +39 -0
- aisot-0.1.0/aisot/__init__.py +270 -0
- aisot-0.1.0/aisot.egg-info/PKG-INFO +52 -0
- aisot-0.1.0/aisot.egg-info/SOURCES.txt +9 -0
- aisot-0.1.0/aisot.egg-info/dependency_links.txt +1 -0
- aisot-0.1.0/aisot.egg-info/requires.txt +1 -0
- aisot-0.1.0/aisot.egg-info/top_level.txt +1 -0
- aisot-0.1.0/pyproject.toml +21 -0
- aisot-0.1.0/setup.cfg +4 -0
- aisot-0.1.0/tests/test_client.py +101 -0
aisot-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aisot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Publisher SDK for aisourceoftruth.com, the public record of the AI agent economy.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://aisourceoftruth.com
|
|
7
|
+
Project-URL: Source, https://github.com/sellingascode/aisourceoftruth/tree/main/packages/python
|
|
8
|
+
Project-URL: Registry, https://aisourceoftruth.com/publish
|
|
9
|
+
Keywords: provenance,verification,registry,ai,aisot
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: cryptography>=42
|
|
13
|
+
|
|
14
|
+
# aisot (Python SDK)
|
|
15
|
+
|
|
16
|
+
Publisher SDK for [aisourceoftruth.com](https://aisourceoftruth.com), the
|
|
17
|
+
neutral AI provenance and verification registry. Mirrors `@aisourceoftruth/sdk` (JS):
|
|
18
|
+
every publisher publishes through the public front door; there is no private
|
|
19
|
+
ingest path anywhere. The private key never leaves your process.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from aisot import AisotClient, generate_key, sha256_hex
|
|
23
|
+
|
|
24
|
+
public_jwk, private_jwk = generate_key()
|
|
25
|
+
client = AisotClient(origin="https://aisourceoftruth.com")
|
|
26
|
+
|
|
27
|
+
reg = client.register_publisher(
|
|
28
|
+
id="my-publisher", name="My Publisher",
|
|
29
|
+
public_jwk=public_jwk, private_jwk=private_jwk,
|
|
30
|
+
)
|
|
31
|
+
client.key_id = reg["key_id"]
|
|
32
|
+
client.private_jwk = private_jwk
|
|
33
|
+
client.authenticate(reg["client_id"], reg["client_secret"])
|
|
34
|
+
|
|
35
|
+
record = client.provenance_record(
|
|
36
|
+
artifact_digest=sha256_hex(open("report.md", "rb").read()),
|
|
37
|
+
artifact_type="text/markdown", creator="my-publisher", model="gpt-x",
|
|
38
|
+
)
|
|
39
|
+
out = client.publish(record)
|
|
40
|
+
print(out["url"]) # the permanent public record page
|
|
41
|
+
|
|
42
|
+
# Verify-before-transact:
|
|
43
|
+
result = client.verify_batch([out["digest"]])
|
|
44
|
+
assert result["results"][0]["signature"]["valid"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Signing is ES256 (ECDSA P-256, raw `r||s` signatures, matching the registry's
|
|
48
|
+
WebCrypto verification) over the exact request bytes. Idempotency-Key headers
|
|
49
|
+
are automatic; a retried call replays the original result.
|
|
50
|
+
|
|
51
|
+
HTTP uses the standard library (`urllib`); the only dependency is
|
|
52
|
+
`cryptography` for ES256.
|
aisot-0.1.0/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# aisot (Python SDK)
|
|
2
|
+
|
|
3
|
+
Publisher SDK for [aisourceoftruth.com](https://aisourceoftruth.com), the
|
|
4
|
+
neutral AI provenance and verification registry. Mirrors `@aisourceoftruth/sdk` (JS):
|
|
5
|
+
every publisher publishes through the public front door; there is no private
|
|
6
|
+
ingest path anywhere. The private key never leaves your process.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from aisot import AisotClient, generate_key, sha256_hex
|
|
10
|
+
|
|
11
|
+
public_jwk, private_jwk = generate_key()
|
|
12
|
+
client = AisotClient(origin="https://aisourceoftruth.com")
|
|
13
|
+
|
|
14
|
+
reg = client.register_publisher(
|
|
15
|
+
id="my-publisher", name="My Publisher",
|
|
16
|
+
public_jwk=public_jwk, private_jwk=private_jwk,
|
|
17
|
+
)
|
|
18
|
+
client.key_id = reg["key_id"]
|
|
19
|
+
client.private_jwk = private_jwk
|
|
20
|
+
client.authenticate(reg["client_id"], reg["client_secret"])
|
|
21
|
+
|
|
22
|
+
record = client.provenance_record(
|
|
23
|
+
artifact_digest=sha256_hex(open("report.md", "rb").read()),
|
|
24
|
+
artifact_type="text/markdown", creator="my-publisher", model="gpt-x",
|
|
25
|
+
)
|
|
26
|
+
out = client.publish(record)
|
|
27
|
+
print(out["url"]) # the permanent public record page
|
|
28
|
+
|
|
29
|
+
# Verify-before-transact:
|
|
30
|
+
result = client.verify_batch([out["digest"]])
|
|
31
|
+
assert result["results"][0]["signature"]["valid"]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Signing is ES256 (ECDSA P-256, raw `r||s` signatures, matching the registry's
|
|
35
|
+
WebCrypto verification) over the exact request bytes. Idempotency-Key headers
|
|
36
|
+
are automatic; a retried call replays the original result.
|
|
37
|
+
|
|
38
|
+
HTTP uses the standard library (`urllib`); the only dependency is
|
|
39
|
+
`cryptography` for ES256.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# aisot - the Python publisher SDK for aisourceoftruth.com.
|
|
2
|
+
#
|
|
3
|
+
# Mirrors @aisourceoftruth/sdk (JS): every publisher publishes through the public front
|
|
4
|
+
# door; there is no private ingest path anywhere. The private key never leaves
|
|
5
|
+
# the caller's process. HTTP is stdlib urllib; the only dependency is
|
|
6
|
+
# `cryptography` for ES256 (ECDSA P-256), signing raw r||s to match the
|
|
7
|
+
# registry's WebCrypto verification.
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json as _json
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
__all__ = ["AisotClient", "AisotError", "generate_key", "sha256_hex"]
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _b64url_encode(data: bytes) -> str:
|
|
25
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _b64url_decode(s: str) -> bytes:
|
|
29
|
+
pad = "=" * (-len(s) % 4)
|
|
30
|
+
return base64.urlsafe_b64decode(s + pad)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha256_hex(data) -> str:
|
|
34
|
+
"""sha256 as lowercase hex. Digest addressing uses this everywhere."""
|
|
35
|
+
if isinstance(data, str):
|
|
36
|
+
data = data.encode("utf-8")
|
|
37
|
+
return hashlib.sha256(data).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_key():
|
|
41
|
+
"""Generate an ES256 (ECDSA P-256) keypair as JWK dicts.
|
|
42
|
+
|
|
43
|
+
Returns (public_jwk, private_jwk). The private JWK stays with the caller
|
|
44
|
+
and is never sent to the registry.
|
|
45
|
+
"""
|
|
46
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
47
|
+
|
|
48
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
49
|
+
numbers = key.private_numbers()
|
|
50
|
+
pub = numbers.public_numbers
|
|
51
|
+
x = pub.x.to_bytes(32, "big")
|
|
52
|
+
y = pub.y.to_bytes(32, "big")
|
|
53
|
+
d = numbers.private_value.to_bytes(32, "big")
|
|
54
|
+
public_jwk = {"kty": "EC", "crv": "P-256", "x": _b64url_encode(x), "y": _b64url_encode(y)}
|
|
55
|
+
private_jwk = dict(public_jwk, d=_b64url_encode(d))
|
|
56
|
+
return public_jwk, private_jwk
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sign_es256(private_jwk: Dict[str, str], data: bytes) -> str:
|
|
60
|
+
"""ES256 detached signature over exact bytes, raw r||s, base64url."""
|
|
61
|
+
from cryptography.hazmat.primitives import hashes
|
|
62
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
63
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
|
64
|
+
|
|
65
|
+
d = int.from_bytes(_b64url_decode(private_jwk["d"]), "big")
|
|
66
|
+
key = ec.derive_private_key(d, ec.SECP256R1())
|
|
67
|
+
der = key.sign(data, ec.ECDSA(hashes.SHA256()))
|
|
68
|
+
r, s = decode_dss_signature(der)
|
|
69
|
+
return _b64url_encode(r.to_bytes(32, "big") + s.to_bytes(32, "big"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify_es256(public_jwk: Dict[str, str], data: bytes, signature_b64url: str) -> bool:
|
|
73
|
+
"""Verify a raw r||s ES256 signature against a public JWK."""
|
|
74
|
+
from cryptography.exceptions import InvalidSignature
|
|
75
|
+
from cryptography.hazmat.primitives import hashes
|
|
76
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
77
|
+
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
78
|
+
|
|
79
|
+
raw = _b64url_decode(signature_b64url)
|
|
80
|
+
if len(raw) != 64:
|
|
81
|
+
return False
|
|
82
|
+
r = int.from_bytes(raw[:32], "big")
|
|
83
|
+
s = int.from_bytes(raw[32:], "big")
|
|
84
|
+
x = int.from_bytes(_b64url_decode(public_jwk["x"]), "big")
|
|
85
|
+
y = int.from_bytes(_b64url_decode(public_jwk["y"]), "big")
|
|
86
|
+
pub = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
|
|
87
|
+
try:
|
|
88
|
+
pub.verify(encode_dss_signature(r, s), data, ec.ECDSA(hashes.SHA256()))
|
|
89
|
+
return True
|
|
90
|
+
except InvalidSignature:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AisotError(Exception):
|
|
95
|
+
"""A typed registry error: carries .status and the error body dict."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, status: int, body: Dict[str, Any]):
|
|
98
|
+
super().__init__(body.get("message") or f"HTTP {status}")
|
|
99
|
+
self.status = status
|
|
100
|
+
self.body = body
|
|
101
|
+
self.code = body.get("code")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _now_iso() -> str:
|
|
105
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AisotClient:
|
|
109
|
+
"""The publisher client. `transport` is injectable for tests: a callable
|
|
110
|
+
(url, method, headers, body_bytes) -> (status, body_text)."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, origin: str = "https://aisourceoftruth.com", token: Optional[str] = None,
|
|
113
|
+
key_id: Optional[str] = None, private_jwk: Optional[Dict[str, str]] = None,
|
|
114
|
+
transport=None):
|
|
115
|
+
self.origin = origin.rstrip("/")
|
|
116
|
+
self.token = token
|
|
117
|
+
self.key_id = key_id
|
|
118
|
+
self.private_jwk = private_jwk
|
|
119
|
+
self._transport = transport or self._urllib_transport
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _urllib_transport(url: str, method: str, headers: Dict[str, str], body: Optional[bytes]):
|
|
123
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
124
|
+
try:
|
|
125
|
+
with urllib.request.urlopen(req) as res:
|
|
126
|
+
return res.status, res.read().decode("utf-8")
|
|
127
|
+
except urllib.error.HTTPError as e:
|
|
128
|
+
return e.code, e.read().decode("utf-8")
|
|
129
|
+
|
|
130
|
+
def _json(self, path: str, method: str = "GET", headers: Optional[Dict[str, str]] = None,
|
|
131
|
+
body: Optional[bytes] = None) -> Dict[str, Any]:
|
|
132
|
+
status, text = self._transport(f"{self.origin}{path}", method, headers or {}, body)
|
|
133
|
+
try:
|
|
134
|
+
parsed = _json.loads(text)
|
|
135
|
+
except ValueError:
|
|
136
|
+
parsed = {"raw": text}
|
|
137
|
+
if status >= 400:
|
|
138
|
+
raise AisotError(status, parsed)
|
|
139
|
+
return parsed
|
|
140
|
+
|
|
141
|
+
# -- registration + auth ------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def register_publisher(self, id: str, name: str, public_jwk: Dict[str, str],
|
|
144
|
+
private_jwk: Optional[Dict[str, str]] = None, klass: Optional[str] = None,
|
|
145
|
+
domain: Optional[str] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
146
|
+
"""Register identity + public key. Proof-of-possession: the body is
|
|
147
|
+
signed with the private half of the key being registered."""
|
|
148
|
+
signing = private_jwk or self.private_jwk
|
|
149
|
+
if not signing:
|
|
150
|
+
raise ValueError("register_publisher needs private_jwk (proof-of-possession of the key being registered)")
|
|
151
|
+
payload: Dict[str, Any] = {"id": id, "name": name, "public_jwk": public_jwk}
|
|
152
|
+
if klass:
|
|
153
|
+
payload["class"] = klass
|
|
154
|
+
if domain:
|
|
155
|
+
payload["domain"] = domain
|
|
156
|
+
body = _json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
157
|
+
return self._json("/v1/publishers", "POST", {
|
|
158
|
+
"content-type": "application/json",
|
|
159
|
+
"x-aisot-signature": sign_es256(signing, body),
|
|
160
|
+
"idempotency-key": idempotency_key or f"py-reg-{sha256_hex(body)}",
|
|
161
|
+
}, body)
|
|
162
|
+
|
|
163
|
+
def authenticate(self, client_id: str, client_secret: str, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
164
|
+
payload: Dict[str, Any] = {"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}
|
|
165
|
+
if scope:
|
|
166
|
+
payload["scope"] = scope
|
|
167
|
+
out = self._json("/oauth/token", "POST", {"content-type": "application/json"},
|
|
168
|
+
_json.dumps(payload).encode("utf-8"))
|
|
169
|
+
self.token = out["access_token"]
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
# -- records ------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def provenance_record(self, artifact_digest: str, creator: str, model: str,
|
|
175
|
+
artifact_type: str = "text/markdown", version: str = "1",
|
|
176
|
+
inputs: Optional[List[str]] = None, lineage: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
177
|
+
record: Dict[str, Any] = {
|
|
178
|
+
"profile": "provenance",
|
|
179
|
+
"artifact": {"digest": artifact_digest, "type": artifact_type, "version": version},
|
|
180
|
+
"creator": creator,
|
|
181
|
+
"model": model,
|
|
182
|
+
"created_at": _now_iso(),
|
|
183
|
+
}
|
|
184
|
+
if inputs:
|
|
185
|
+
record["inputs"] = inputs
|
|
186
|
+
if lineage:
|
|
187
|
+
record["lineage"] = lineage
|
|
188
|
+
return record
|
|
189
|
+
|
|
190
|
+
def publish(self, record: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
191
|
+
"""Build, sign, and POST a record. Digest, signature, and body are all
|
|
192
|
+
over the SAME bytes (content-addressed)."""
|
|
193
|
+
if not self.token:
|
|
194
|
+
raise ValueError("no token: call authenticate() first")
|
|
195
|
+
if not self.private_jwk or not self.key_id:
|
|
196
|
+
raise ValueError("no signing key: set private_jwk + key_id")
|
|
197
|
+
body = _json.dumps(record, separators=(",", ":")).encode("utf-8")
|
|
198
|
+
digest = sha256_hex(body)
|
|
199
|
+
return self._json("/v1/records", "POST", {
|
|
200
|
+
"authorization": f"Bearer {self.token}",
|
|
201
|
+
"content-type": "application/vnd.aisot.record+json",
|
|
202
|
+
"idempotency-key": idempotency_key or f"py-{digest}",
|
|
203
|
+
"x-aisot-key-id": self.key_id,
|
|
204
|
+
"x-aisot-signature": sign_es256(self.private_jwk, body),
|
|
205
|
+
"x-aisot-digest": digest,
|
|
206
|
+
}, body)
|
|
207
|
+
|
|
208
|
+
# -- verification -------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def verify(self, digest: str) -> Dict[str, Any]:
|
|
211
|
+
"""Resolve a record and locally re-verify its signature against the
|
|
212
|
+
public key directory (verify-before-transact, no trust in transport)."""
|
|
213
|
+
record = self._json(f"/r/{digest}", "GET", {"accept": "application/json"})
|
|
214
|
+
raw_status, raw_text = self._transport(f"{self.origin}/r/{digest}?format=json&raw=1", "GET",
|
|
215
|
+
{"accept": "application/vnd.aisot.record+json"}, None)
|
|
216
|
+
registry = record.get("registry", {}) if isinstance(record, dict) else {}
|
|
217
|
+
key_id = registry.get("key_id")
|
|
218
|
+
sig = registry.get("signature")
|
|
219
|
+
verified = False
|
|
220
|
+
if raw_status == 200 and key_id and sig:
|
|
221
|
+
keys = self._json(f"/v1/keys/{key_id.split('#')[0]}", "GET", {"accept": "application/json"})
|
|
222
|
+
jwk = next((_json.loads(k["public_jwk"]) if isinstance(k.get("public_jwk"), str) else k.get("public_jwk")
|
|
223
|
+
for k in keys.get("keys", []) if k.get("key_id") == key_id), None)
|
|
224
|
+
if jwk:
|
|
225
|
+
verified = verify_es256(jwk, raw_text.encode("utf-8"), sig)
|
|
226
|
+
return {"record": record, "signature_verifies": verified}
|
|
227
|
+
|
|
228
|
+
def verify_batch(self, digests: List[str]) -> Dict[str, Any]:
|
|
229
|
+
return self._json("/v1/verify-batch", "POST", {"content-type": "application/json"},
|
|
230
|
+
_json.dumps({"digests": digests}).encode("utf-8"))
|
|
231
|
+
|
|
232
|
+
# -- payback + publisher appends -----------------------------------------
|
|
233
|
+
|
|
234
|
+
def _signed_append(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
235
|
+
if not self.token:
|
|
236
|
+
raise ValueError("no token: call authenticate() first")
|
|
237
|
+
if not self.private_jwk or not self.key_id:
|
|
238
|
+
raise ValueError("no signing key: set private_jwk + key_id")
|
|
239
|
+
body = _json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
240
|
+
return self._json(path, "POST", {
|
|
241
|
+
"authorization": f"Bearer {self.token}",
|
|
242
|
+
"content-type": "application/json",
|
|
243
|
+
"idempotency-key": f"py-{sha256_hex(body)}",
|
|
244
|
+
"x-aisot-key-id": self.key_id,
|
|
245
|
+
"x-aisot-signature": sign_es256(self.private_jwk, body),
|
|
246
|
+
}, body)
|
|
247
|
+
|
|
248
|
+
def publish_source_claim(self, source_id: str, method: str, proof: str) -> Dict[str, Any]:
|
|
249
|
+
return self._signed_append("/v1/source-claims", {"source_id": source_id, "method": method, "proof": proof})
|
|
250
|
+
|
|
251
|
+
def verify_source_claim(self, source_id: str) -> Dict[str, Any]:
|
|
252
|
+
return self._signed_append("/v1/source-claims/verify", {"source_id": source_id})
|
|
253
|
+
|
|
254
|
+
def publish_terms(self, source_id: str, license_id: str) -> Dict[str, Any]:
|
|
255
|
+
return self._signed_append("/v1/terms", {"source_id": source_id, "license_id": license_id})
|
|
256
|
+
|
|
257
|
+
def publish_erratum(self, record_digest: str, correction: str, claim_id: Optional[str] = None) -> Dict[str, Any]:
|
|
258
|
+
payload: Dict[str, Any] = {"record_digest": record_digest, "correction": correction}
|
|
259
|
+
if claim_id:
|
|
260
|
+
payload["claim_id"] = claim_id
|
|
261
|
+
return self._signed_append("/v1/errata", payload)
|
|
262
|
+
|
|
263
|
+
def verify_domain(self) -> Dict[str, Any]:
|
|
264
|
+
if not self.token:
|
|
265
|
+
raise ValueError("no token: call authenticate() first")
|
|
266
|
+
return self._json("/v1/publishers/verify-domain", "POST", {
|
|
267
|
+
"authorization": f"Bearer {self.token}",
|
|
268
|
+
"content-type": "application/json",
|
|
269
|
+
"idempotency-key": f"py-vd-{sha256_hex(self.token)}",
|
|
270
|
+
}, b"{}")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aisot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Publisher SDK for aisourceoftruth.com, the public record of the AI agent economy.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://aisourceoftruth.com
|
|
7
|
+
Project-URL: Source, https://github.com/sellingascode/aisourceoftruth/tree/main/packages/python
|
|
8
|
+
Project-URL: Registry, https://aisourceoftruth.com/publish
|
|
9
|
+
Keywords: provenance,verification,registry,ai,aisot
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: cryptography>=42
|
|
13
|
+
|
|
14
|
+
# aisot (Python SDK)
|
|
15
|
+
|
|
16
|
+
Publisher SDK for [aisourceoftruth.com](https://aisourceoftruth.com), the
|
|
17
|
+
neutral AI provenance and verification registry. Mirrors `@aisourceoftruth/sdk` (JS):
|
|
18
|
+
every publisher publishes through the public front door; there is no private
|
|
19
|
+
ingest path anywhere. The private key never leaves your process.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from aisot import AisotClient, generate_key, sha256_hex
|
|
23
|
+
|
|
24
|
+
public_jwk, private_jwk = generate_key()
|
|
25
|
+
client = AisotClient(origin="https://aisourceoftruth.com")
|
|
26
|
+
|
|
27
|
+
reg = client.register_publisher(
|
|
28
|
+
id="my-publisher", name="My Publisher",
|
|
29
|
+
public_jwk=public_jwk, private_jwk=private_jwk,
|
|
30
|
+
)
|
|
31
|
+
client.key_id = reg["key_id"]
|
|
32
|
+
client.private_jwk = private_jwk
|
|
33
|
+
client.authenticate(reg["client_id"], reg["client_secret"])
|
|
34
|
+
|
|
35
|
+
record = client.provenance_record(
|
|
36
|
+
artifact_digest=sha256_hex(open("report.md", "rb").read()),
|
|
37
|
+
artifact_type="text/markdown", creator="my-publisher", model="gpt-x",
|
|
38
|
+
)
|
|
39
|
+
out = client.publish(record)
|
|
40
|
+
print(out["url"]) # the permanent public record page
|
|
41
|
+
|
|
42
|
+
# Verify-before-transact:
|
|
43
|
+
result = client.verify_batch([out["digest"]])
|
|
44
|
+
assert result["results"][0]["signature"]["valid"]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Signing is ES256 (ECDSA P-256, raw `r||s` signatures, matching the registry's
|
|
48
|
+
WebCrypto verification) over the exact request bytes. Idempotency-Key headers
|
|
49
|
+
are automatic; a retried call replays the original result.
|
|
50
|
+
|
|
51
|
+
HTTP uses the standard library (`urllib`); the only dependency is
|
|
52
|
+
`cryptography` for ES256.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=42
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aisot
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aisot"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Publisher SDK for aisourceoftruth.com, the public record of the AI agent economy."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["cryptography>=42"]
|
|
13
|
+
keywords = ["provenance", "verification", "registry", "ai", "aisot"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://aisourceoftruth.com"
|
|
17
|
+
Source = "https://github.com/sellingascode/aisourceoftruth/tree/main/packages/python"
|
|
18
|
+
Registry = "https://aisourceoftruth.com/publish"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["aisot*"]
|
aisot-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Unit tests for the aisot Python SDK. No network: a fake transport captures
|
|
2
|
+
# requests; crypto tests use the real `cryptography` primitives and skip
|
|
3
|
+
# cleanly when the dependency is not installed.
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import cryptography # noqa: F401
|
|
9
|
+
HAVE_CRYPTO = True
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
HAVE_CRYPTO = False
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
17
|
+
|
|
18
|
+
from aisot import AisotClient, AisotError, sha256_hex # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeTransport:
|
|
22
|
+
def __init__(self, responses):
|
|
23
|
+
self.responses = list(responses)
|
|
24
|
+
self.calls = []
|
|
25
|
+
|
|
26
|
+
def __call__(self, url, method, headers, body):
|
|
27
|
+
self.calls.append({"url": url, "method": method, "headers": dict(headers), "body": body})
|
|
28
|
+
status, payload = self.responses.pop(0)
|
|
29
|
+
return status, json.dumps(payload)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestDigest(unittest.TestCase):
|
|
33
|
+
def test_sha256_hex_matches_known_vector(self):
|
|
34
|
+
self.assertEqual(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
|
35
|
+
self.assertEqual(sha256_hex("abc"), sha256_hex(b"abc"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@unittest.skipUnless(HAVE_CRYPTO, "cryptography not installed")
|
|
39
|
+
class TestCrypto(unittest.TestCase):
|
|
40
|
+
def test_sign_verify_roundtrip_raw_rs(self):
|
|
41
|
+
from aisot import generate_key, sign_es256, verify_es256
|
|
42
|
+
|
|
43
|
+
public_jwk, private_jwk = generate_key()
|
|
44
|
+
self.assertEqual(public_jwk["kty"], "EC")
|
|
45
|
+
self.assertEqual(public_jwk["crv"], "P-256")
|
|
46
|
+
self.assertNotIn("d", public_jwk)
|
|
47
|
+
data = b'{"profile":"provenance"}'
|
|
48
|
+
sig = sign_es256(private_jwk, data)
|
|
49
|
+
self.assertTrue(verify_es256(public_jwk, data, sig))
|
|
50
|
+
self.assertFalse(verify_es256(public_jwk, b"tampered", sig))
|
|
51
|
+
|
|
52
|
+
def test_registration_is_signed_and_idempotent(self):
|
|
53
|
+
from aisot import generate_key, verify_es256
|
|
54
|
+
|
|
55
|
+
public_jwk, private_jwk = generate_key()
|
|
56
|
+
t = FakeTransport([(201, {"publisher_id": "p", "key_id": "p#1", "client_id": "c", "client_secret": "s"})])
|
|
57
|
+
client = AisotClient(origin="https://reg.example", transport=t)
|
|
58
|
+
client.register_publisher(id="p", name="P", public_jwk=public_jwk, private_jwk=private_jwk)
|
|
59
|
+
call = t.calls[0]
|
|
60
|
+
self.assertEqual(call["url"], "https://reg.example/v1/publishers")
|
|
61
|
+
self.assertIn("idempotency-key", call["headers"])
|
|
62
|
+
# Proof-of-possession signature verifies over the exact body bytes.
|
|
63
|
+
self.assertTrue(verify_es256(public_jwk, call["body"], call["headers"]["x-aisot-signature"]))
|
|
64
|
+
|
|
65
|
+
def test_publish_signs_exact_bytes_and_declares_digest(self):
|
|
66
|
+
from aisot import generate_key, verify_es256
|
|
67
|
+
|
|
68
|
+
public_jwk, private_jwk = generate_key()
|
|
69
|
+
t = FakeTransport([(201, {"digest": "x", "url": "https://reg.example/r/x"})])
|
|
70
|
+
client = AisotClient(origin="https://reg.example", token="tok", key_id="p#1", private_jwk=private_jwk, transport=t)
|
|
71
|
+
record = client.provenance_record(artifact_digest="ab" * 32, creator="p", model="m")
|
|
72
|
+
client.publish(record)
|
|
73
|
+
call = t.calls[0]
|
|
74
|
+
self.assertEqual(call["headers"]["x-aisot-digest"], sha256_hex(call["body"]))
|
|
75
|
+
self.assertTrue(verify_es256(public_jwk, call["body"], call["headers"]["x-aisot-signature"]))
|
|
76
|
+
self.assertEqual(call["headers"]["idempotency-key"], f"py-{sha256_hex(call['body'])}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestClientBehavior(unittest.TestCase):
|
|
80
|
+
def test_typed_errors_raise_with_code(self):
|
|
81
|
+
t = FakeTransport([(429, {"code": "rate_limited", "message": "slow down"})])
|
|
82
|
+
client = AisotClient(origin="https://reg.example", transport=t)
|
|
83
|
+
with self.assertRaises(AisotError) as ctx:
|
|
84
|
+
client.verify_batch(["a" * 64])
|
|
85
|
+
self.assertEqual(ctx.exception.status, 429)
|
|
86
|
+
self.assertEqual(ctx.exception.code, "rate_limited")
|
|
87
|
+
|
|
88
|
+
def test_publish_requires_credentials(self):
|
|
89
|
+
client = AisotClient(transport=FakeTransport([]))
|
|
90
|
+
with self.assertRaises(ValueError):
|
|
91
|
+
client.publish({"profile": "provenance"})
|
|
92
|
+
|
|
93
|
+
def test_authenticate_stores_token(self):
|
|
94
|
+
t = FakeTransport([(200, {"access_token": "tok123", "expires_in": 3600, "scope": "publish:provenance"})])
|
|
95
|
+
client = AisotClient(origin="https://reg.example", transport=t)
|
|
96
|
+
client.authenticate("cid", "csk")
|
|
97
|
+
self.assertEqual(client.token, "tok123")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
unittest.main()
|