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 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,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ aisot/__init__.py
4
+ aisot.egg-info/PKG-INFO
5
+ aisot.egg-info/SOURCES.txt
6
+ aisot.egg-info/dependency_links.txt
7
+ aisot.egg-info/requires.txt
8
+ aisot.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()