inamprotocol 0.2.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.
- inamprotocol-0.2.0/PKG-INFO +59 -0
- inamprotocol-0.2.0/README.md +35 -0
- inamprotocol-0.2.0/inamprotocol/__init__.py +14 -0
- inamprotocol-0.2.0/inamprotocol/canonical.py +32 -0
- inamprotocol-0.2.0/inamprotocol/client.py +203 -0
- inamprotocol-0.2.0/inamprotocol/keys.py +79 -0
- inamprotocol-0.2.0/inamprotocol/receipt.py +35 -0
- inamprotocol-0.2.0/inamprotocol.egg-info/PKG-INFO +59 -0
- inamprotocol-0.2.0/inamprotocol.egg-info/SOURCES.txt +15 -0
- inamprotocol-0.2.0/inamprotocol.egg-info/dependency_links.txt +1 -0
- inamprotocol-0.2.0/inamprotocol.egg-info/requires.txt +2 -0
- inamprotocol-0.2.0/inamprotocol.egg-info/top_level.txt +1 -0
- inamprotocol-0.2.0/pyproject.toml +36 -0
- inamprotocol-0.2.0/setup.cfg +4 -0
- inamprotocol-0.2.0/tests/test_canonical.py +19 -0
- inamprotocol-0.2.0/tests/test_interop.py +65 -0
- inamprotocol-0.2.0/tests/test_keys.py +28 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inamprotocol
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Reference Python SDK for the INAM Protocol — agent identity, execution receipts, jobs, and reputation
|
|
5
|
+
Author: INAM Protocol
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://inamprotocol.org
|
|
8
|
+
Project-URL: Documentation, https://docs.inamprotocol.org
|
|
9
|
+
Project-URL: Repository, https://github.com/inamprotocol/inam-protocol
|
|
10
|
+
Project-URL: Specification, https://docs.inamprotocol.org/spec/
|
|
11
|
+
Project-URL: Changelog, https://github.com/inamprotocol/inam-protocol/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: inam,agent,ai-agent,identity,reputation,did,protocol
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: cryptography>=42.0.0
|
|
23
|
+
Requires-Dist: base58>=2.1.0
|
|
24
|
+
|
|
25
|
+
# inamprotocol (Python SDK)
|
|
26
|
+
|
|
27
|
+
Reference Python client for the INAM Protocol — parity with the TypeScript `InamClient` in `../src/sdk/client.ts`. See [`../SPEC.md`](../SPEC.md) for the full protocol specification.
|
|
28
|
+
|
|
29
|
+
## Install (development)
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
python -m venv .venv
|
|
33
|
+
./.venv/Scripts/python.exe -m pip install -e . pytest # Windows
|
|
34
|
+
# ./.venv/bin/python -m pip install -e . pytest # macOS/Linux
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Test
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
./.venv/Scripts/python.exe -m pytest -v
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`tests/test_interop.py` is the important one: it checks this SDK's `did:key` encoding, canonical JSON, and Ed25519 signing against fixed values generated once by the TypeScript reference implementation (`../scripts/interop-vectors.ts`). If a change here ever breaks that test, a receipt signed by this SDK would stop verifying against a registry or another SDK written in a different language — that's the whole point of the test.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from inamprotocol import InamClient, generate_keypair
|
|
49
|
+
|
|
50
|
+
keypair = generate_keypair()
|
|
51
|
+
client = InamClient("http://localhost:4021", keypair)
|
|
52
|
+
|
|
53
|
+
profile = client.register_agent(["document-extraction"], {"name": "My Agent"})
|
|
54
|
+
print(profile["id"]) # did:key:z...
|
|
55
|
+
|
|
56
|
+
reputation = client.get_reputation(profile["id"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
See `examples/interop_worker.py` for a full worker-side flow (register, link an external identity, submit signed Execution Receipt drafts), and `../scripts/run-interop-demo.sh` for the end-to-end cross-language demo (a TypeScript requester and this Python worker doing real business through the same live registry).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# inamprotocol (Python SDK)
|
|
2
|
+
|
|
3
|
+
Reference Python client for the INAM Protocol — parity with the TypeScript `InamClient` in `../src/sdk/client.ts`. See [`../SPEC.md`](../SPEC.md) for the full protocol specification.
|
|
4
|
+
|
|
5
|
+
## Install (development)
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
python -m venv .venv
|
|
9
|
+
./.venv/Scripts/python.exe -m pip install -e . pytest # Windows
|
|
10
|
+
# ./.venv/bin/python -m pip install -e . pytest # macOS/Linux
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Test
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
./.venv/Scripts/python.exe -m pytest -v
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`tests/test_interop.py` is the important one: it checks this SDK's `did:key` encoding, canonical JSON, and Ed25519 signing against fixed values generated once by the TypeScript reference implementation (`../scripts/interop-vectors.ts`). If a change here ever breaks that test, a receipt signed by this SDK would stop verifying against a registry or another SDK written in a different language — that's the whole point of the test.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from inamprotocol import InamClient, generate_keypair
|
|
25
|
+
|
|
26
|
+
keypair = generate_keypair()
|
|
27
|
+
client = InamClient("http://localhost:4021", keypair)
|
|
28
|
+
|
|
29
|
+
profile = client.register_agent(["document-extraction"], {"name": "My Agent"})
|
|
30
|
+
print(profile["id"]) # did:key:z...
|
|
31
|
+
|
|
32
|
+
reputation = client.get_reputation(profile["id"])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
See `examples/interop_worker.py` for a full worker-side flow (register, link an external identity, submit signed Execution Receipt drafts), and `../scripts/run-interop-demo.sh` for the end-to-end cross-language demo (a TypeScript requester and this Python worker doing real business through the same live registry).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .keys import Keypair, generate_keypair, public_key_to_did, did_to_public_key, sign, verify
|
|
2
|
+
from .canonical import canonicalize
|
|
3
|
+
from .client import InamClient
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Keypair",
|
|
7
|
+
"generate_keypair",
|
|
8
|
+
"public_key_to_did",
|
|
9
|
+
"did_to_public_key",
|
|
10
|
+
"sign",
|
|
11
|
+
"verify",
|
|
12
|
+
"canonicalize",
|
|
13
|
+
"InamClient",
|
|
14
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Canonical JSON serialization — must match src/crypto/canonical.ts byte-for-byte.
|
|
2
|
+
|
|
3
|
+
This is the one piece of logic every INAM SDK, in any language, has to agree
|
|
4
|
+
on precisely: receipt signatures are verified by independently re-canonicalizing
|
|
5
|
+
the same structured data on the other side, so any divergence here would make
|
|
6
|
+
cross-language signatures fail to verify. See SPEC.md section 7.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def canonicalize(value: Any) -> str:
|
|
14
|
+
return _stringify(value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _stringify(value: Any) -> str:
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
keys = sorted(value.keys())
|
|
20
|
+
entries = []
|
|
21
|
+
for key in keys:
|
|
22
|
+
v = value[key]
|
|
23
|
+
if v is None:
|
|
24
|
+
# Mirrors the JS side filtering out `undefined`-valued keys —
|
|
25
|
+
# None is our cross-language stand-in for "field omitted",
|
|
26
|
+
# never a meaningful JSON null in these schemas.
|
|
27
|
+
continue
|
|
28
|
+
entries.append(json.dumps(key, ensure_ascii=False) + ":" + _stringify(v))
|
|
29
|
+
return "{" + ",".join(entries) + "}"
|
|
30
|
+
if isinstance(value, list):
|
|
31
|
+
return "[" + ",".join(_stringify(v) for v in value) + "]"
|
|
32
|
+
return json.dumps(value, ensure_ascii=False)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""INAM Protocol reference client — parity with src/sdk/client.ts (InamClient).
|
|
2
|
+
|
|
3
|
+
Everything here is transport plumbing plus the two signature schemes (HTTP
|
|
4
|
+
request signing, receipt content signing). An agent framework's tool-calling
|
|
5
|
+
layer would wrap these same calls as `search_jobs` / `verify_agent` /
|
|
6
|
+
`submit_work`-style tools.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from .canonical import canonicalize
|
|
17
|
+
from .keys import Keypair, sha256_hex, sign, to_base64
|
|
18
|
+
from .receipt import build_signable_content
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InamApiError(Exception):
|
|
22
|
+
def __init__(self, method: str, path: str, status: int, payload: Any):
|
|
23
|
+
self.status = status
|
|
24
|
+
self.payload = payload
|
|
25
|
+
super().__init__(f"{method} {path} -> {status}: {payload}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InamClient:
|
|
29
|
+
def __init__(self, base_url: str, keypair: Keypair):
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.keypair = keypair
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def did(self) -> str:
|
|
35
|
+
return self.keypair.did
|
|
36
|
+
|
|
37
|
+
def _request(
|
|
38
|
+
self,
|
|
39
|
+
method: str,
|
|
40
|
+
path: str,
|
|
41
|
+
body: Optional[Dict[str, Any]] = None,
|
|
42
|
+
idempotency_key: Optional[str] = None,
|
|
43
|
+
) -> Any:
|
|
44
|
+
raw_body = json.dumps(body, separators=(",", ":")) if body is not None else ""
|
|
45
|
+
timestamp = str(int(time.time() * 1000))
|
|
46
|
+
body_hash = sha256_hex(raw_body)
|
|
47
|
+
signing_string = f"{method.upper()}\n{path}\n{timestamp}\n{body_hash}"
|
|
48
|
+
signature = to_base64(sign(signing_string.encode("utf-8"), self.keypair.private_key))
|
|
49
|
+
|
|
50
|
+
headers = {
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
# Cloudflare's bot protection on *.workers.dev flags Python's
|
|
53
|
+
# default `Python-urllib/x.y` User-Agent; identify honestly instead.
|
|
54
|
+
"user-agent": "inamprotocol-python-sdk/0.1.0",
|
|
55
|
+
"inam-agent": self.keypair.did,
|
|
56
|
+
"inam-timestamp": timestamp,
|
|
57
|
+
"inam-signature": signature,
|
|
58
|
+
}
|
|
59
|
+
if idempotency_key:
|
|
60
|
+
headers["idempotency-key"] = idempotency_key
|
|
61
|
+
|
|
62
|
+
url = f"{self.base_url}{path}"
|
|
63
|
+
data = raw_body.encode("utf-8") if body is not None else None
|
|
64
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
65
|
+
try:
|
|
66
|
+
with urllib.request.urlopen(req) as res:
|
|
67
|
+
return json.loads(res.read().decode("utf-8"))
|
|
68
|
+
except urllib.error.HTTPError as e:
|
|
69
|
+
payload_text = e.read().decode("utf-8")
|
|
70
|
+
try:
|
|
71
|
+
payload = json.loads(payload_text)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
payload = payload_text
|
|
74
|
+
raise InamApiError(method, path, e.code, payload) from None
|
|
75
|
+
|
|
76
|
+
def register_agent(self, capabilities: List[str], metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
77
|
+
# Omit the key entirely rather than sending it as JSON null: the
|
|
78
|
+
# server's zod schema treats `metadata` as optional-if-absent, not
|
|
79
|
+
# nullable, and Python's json.dumps (unlike JS's JSON.stringify,
|
|
80
|
+
# which drops `undefined`-valued keys) serializes None as null.
|
|
81
|
+
body: Dict[str, Any] = {"capabilities": capabilities}
|
|
82
|
+
if metadata is not None:
|
|
83
|
+
body["metadata"] = metadata
|
|
84
|
+
return self._request("POST", "/v1/agents", body, idempotency_key=f"register:{self.keypair.did}")
|
|
85
|
+
|
|
86
|
+
def get_agent(self, agent_id: str) -> Dict[str, Any]:
|
|
87
|
+
return self._request("GET", f"/v1/agents/{urllib.parse.quote(agent_id, safe='')}")
|
|
88
|
+
|
|
89
|
+
def link_identity(self, protocol: str, value: str) -> Dict[str, Any]:
|
|
90
|
+
return self._request(
|
|
91
|
+
"POST",
|
|
92
|
+
f"/v1/agents/{urllib.parse.quote(self.did, safe='')}/link",
|
|
93
|
+
{"protocol": protocol, "value": value},
|
|
94
|
+
idempotency_key=f"link:{protocol}:{value}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def search_agents(
|
|
98
|
+
self,
|
|
99
|
+
capability: Optional[str] = None,
|
|
100
|
+
min_reputation: Optional[float] = None,
|
|
101
|
+
supports: Optional[str] = None,
|
|
102
|
+
) -> Dict[str, Any]:
|
|
103
|
+
params: Dict[str, str] = {}
|
|
104
|
+
if capability:
|
|
105
|
+
params["capability"] = capability
|
|
106
|
+
if min_reputation is not None:
|
|
107
|
+
params["min_reputation"] = str(min_reputation)
|
|
108
|
+
if supports:
|
|
109
|
+
params["supports"] = supports
|
|
110
|
+
return self._request("GET", f"/v1/agents/search?{urllib.parse.urlencode(params)}")
|
|
111
|
+
|
|
112
|
+
def get_reputation(self, agent_id: str) -> Dict[str, Any]:
|
|
113
|
+
return self._request("GET", f"/v1/agents/{urllib.parse.quote(agent_id, safe='')}/reputation")
|
|
114
|
+
|
|
115
|
+
def list_receipts(self, agent_id: str) -> Dict[str, Any]:
|
|
116
|
+
return self._request("GET", f"/v1/agents/{urllib.parse.quote(agent_id, safe='')}/receipts")
|
|
117
|
+
|
|
118
|
+
def submit_work(self, agent_a_id: str, input: Dict[str, Any]) -> Dict[str, Any]:
|
|
119
|
+
"""Called by the worker (agent_b) once a job is complete, off-network."""
|
|
120
|
+
content = build_signable_content(agent_a_id, self.did, input)
|
|
121
|
+
signing_bytes = canonicalize({**content, "dispute": None}).encode("utf-8")
|
|
122
|
+
signature = to_base64(sign(signing_bytes, self.keypair.private_key))
|
|
123
|
+
body = {**input, "agentAId": agent_a_id, "signature": signature}
|
|
124
|
+
return self._request("POST", "/v1/receipts", body, idempotency_key=f"receipt:{input['jobId']}")
|
|
125
|
+
|
|
126
|
+
def accept_work(self, receipt: Dict[str, Any]) -> Dict[str, Any]:
|
|
127
|
+
"""Called by the requester (agent_a) to accept the worker's submitted result."""
|
|
128
|
+
content = {**receipt, "signatures": None, "status": None, "dispute": None}
|
|
129
|
+
signing_bytes = canonicalize(content).encode("utf-8")
|
|
130
|
+
signature = to_base64(sign(signing_bytes, self.keypair.private_key))
|
|
131
|
+
receipt_id = urllib.parse.quote(receipt["receiptId"], safe="")
|
|
132
|
+
return self._request(
|
|
133
|
+
"POST",
|
|
134
|
+
f"/v1/receipts/{receipt_id}/countersign",
|
|
135
|
+
{"signature": signature},
|
|
136
|
+
idempotency_key=f"countersign:{receipt['receiptId']}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def dispute_receipt(self, receipt_id: str, reason: str) -> Dict[str, Any]:
|
|
140
|
+
encoded = urllib.parse.quote(receipt_id, safe="")
|
|
141
|
+
return self._request(
|
|
142
|
+
"POST",
|
|
143
|
+
f"/v1/receipts/{encoded}/dispute",
|
|
144
|
+
{"reason": reason},
|
|
145
|
+
idempotency_key=f"dispute:{receipt_id}",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# ---- Jobs (SPEC.md section 3) -- optional pre-work discovery/offer/accept ----
|
|
149
|
+
|
|
150
|
+
def post_job(
|
|
151
|
+
self,
|
|
152
|
+
capability: str,
|
|
153
|
+
spec_hash: str,
|
|
154
|
+
budget: Optional[Dict[str, Any]] = None,
|
|
155
|
+
expires_at: Optional[str] = None,
|
|
156
|
+
) -> Dict[str, Any]:
|
|
157
|
+
body: Dict[str, Any] = {"capability": capability, "specHash": spec_hash}
|
|
158
|
+
if budget is not None:
|
|
159
|
+
body["budget"] = budget
|
|
160
|
+
if expires_at is not None:
|
|
161
|
+
body["expiresAt"] = expires_at
|
|
162
|
+
return self._request("POST", "/v1/jobs", body, idempotency_key=f"job:{capability}:{spec_hash}:{time.time()}")
|
|
163
|
+
|
|
164
|
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
165
|
+
return self._request("GET", f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}")
|
|
166
|
+
|
|
167
|
+
def search_jobs(self, capability: Optional[str] = None, status: Optional[str] = None) -> Dict[str, Any]:
|
|
168
|
+
params: Dict[str, str] = {}
|
|
169
|
+
if capability:
|
|
170
|
+
params["capability"] = capability
|
|
171
|
+
if status:
|
|
172
|
+
params["status"] = status
|
|
173
|
+
return self._request("GET", f"/v1/jobs/search?{urllib.parse.urlencode(params)}")
|
|
174
|
+
|
|
175
|
+
def submit_offer(self, job_id: str, message: Optional[str] = None) -> Dict[str, Any]:
|
|
176
|
+
encoded = urllib.parse.quote(job_id, safe="")
|
|
177
|
+
body: Dict[str, Any] = {}
|
|
178
|
+
if message is not None:
|
|
179
|
+
body["message"] = message
|
|
180
|
+
return self._request(
|
|
181
|
+
"POST",
|
|
182
|
+
f"/v1/jobs/{encoded}/offers",
|
|
183
|
+
body,
|
|
184
|
+
idempotency_key=f"offer:{job_id}:{self.did}",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def list_offers(self, job_id: str) -> Dict[str, Any]:
|
|
188
|
+
return self._request("GET", f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}/offers")
|
|
189
|
+
|
|
190
|
+
def accept_offer(self, job_id: str, agent_id: str) -> Dict[str, Any]:
|
|
191
|
+
"""Called by the job's poster to accept one offer."""
|
|
192
|
+
encoded = urllib.parse.quote(job_id, safe="")
|
|
193
|
+
return self._request(
|
|
194
|
+
"POST",
|
|
195
|
+
f"/v1/jobs/{encoded}/accept",
|
|
196
|
+
{"agentId": agent_id},
|
|
197
|
+
idempotency_key=f"accept:{job_id}:{agent_id}",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def cancel_job(self, job_id: str) -> Dict[str, Any]:
|
|
201
|
+
"""Called by the job's poster to cancel a not-yet-completed job."""
|
|
202
|
+
encoded = urllib.parse.quote(job_id, safe="")
|
|
203
|
+
return self._request("POST", f"/v1/jobs/{encoded}/cancel", None, idempotency_key=f"cancel:{job_id}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""did:key (Ed25519) identity — must match src/crypto/keys.ts."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
import base58
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
10
|
+
from cryptography.hazmat.primitives import serialization
|
|
11
|
+
|
|
12
|
+
# multicodec value for ed25519-pub (0xed) as a varint: [0xed, 0x01].
|
|
13
|
+
ED25519_MULTICODEC_PREFIX = bytes([0xED, 0x01])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Keypair:
|
|
18
|
+
did: str
|
|
19
|
+
public_key: bytes
|
|
20
|
+
private_key: Ed25519PrivateKey
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def public_key_to_did(public_key: bytes) -> str:
|
|
24
|
+
prefixed = ED25519_MULTICODEC_PREFIX + public_key
|
|
25
|
+
return "did:key:z" + base58.b58encode(prefixed).decode("ascii")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def did_to_public_key(did: str) -> bytes:
|
|
29
|
+
if not did.startswith("did:key:z"):
|
|
30
|
+
raise ValueError(f"Unsupported DID method: {did}")
|
|
31
|
+
decoded = base58.b58decode(did[len("did:key:z") :])
|
|
32
|
+
if decoded[0:2] != ED25519_MULTICODEC_PREFIX:
|
|
33
|
+
raise ValueError(f"Unsupported key type in DID: {did}")
|
|
34
|
+
return decoded[2:]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generate_keypair() -> Keypair:
|
|
38
|
+
private_key = Ed25519PrivateKey.generate()
|
|
39
|
+
return keypair_from_private_key(private_key)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def keypair_from_raw_private_key(raw_private_key: bytes) -> Keypair:
|
|
43
|
+
private_key = Ed25519PrivateKey.from_private_bytes(raw_private_key)
|
|
44
|
+
return keypair_from_private_key(private_key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def keypair_from_private_key(private_key: Ed25519PrivateKey) -> Keypair:
|
|
48
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
49
|
+
encoding=serialization.Encoding.Raw,
|
|
50
|
+
format=serialization.PublicFormat.Raw,
|
|
51
|
+
)
|
|
52
|
+
return Keypair(did=public_key_to_did(public_bytes), public_key=public_bytes, private_key=private_key)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sign(message: bytes, private_key: Ed25519PrivateKey) -> bytes:
|
|
56
|
+
return private_key.sign(message)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify(signature: bytes, message: bytes, did: str) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
public_key = Ed25519PublicKey.from_public_bytes(did_to_public_key(did))
|
|
62
|
+
public_key.verify(signature, message)
|
|
63
|
+
return True
|
|
64
|
+
except Exception:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def sha256_hex(data: Union[str, bytes]) -> str:
|
|
69
|
+
if isinstance(data, str):
|
|
70
|
+
data = data.encode("utf-8")
|
|
71
|
+
return hashlib.sha256(data).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_base64(data: bytes) -> str:
|
|
75
|
+
return base64.b64encode(data).decode("ascii")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def from_base64(s: str) -> bytes:
|
|
79
|
+
return base64.b64decode(s)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Pure receipt-content logic — must match src/core/receiptContent.ts."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from .canonical import canonicalize
|
|
6
|
+
from .keys import sha256_hex
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_receipt_id(agent_a_id: str, agent_b_id: str, input: Dict[str, Any]) -> str:
|
|
10
|
+
base = {
|
|
11
|
+
"jobId": input["jobId"],
|
|
12
|
+
"agentA": {"id": agent_a_id, "role": "requester"},
|
|
13
|
+
"agentB": {"id": agent_b_id, "role": "worker"},
|
|
14
|
+
"task": input["task"],
|
|
15
|
+
"result": input["result"],
|
|
16
|
+
"settlement": input.get("settlement"),
|
|
17
|
+
"verification": input["verification"],
|
|
18
|
+
}
|
|
19
|
+
return f"sha256:{sha256_hex(canonicalize(base))}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_signable_content(agent_a_id: str, agent_b_id: str, input: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
receipt_id = compute_receipt_id(agent_a_id, agent_b_id, input)
|
|
24
|
+
return {
|
|
25
|
+
"receiptVersion": "1.0",
|
|
26
|
+
"receiptId": receipt_id,
|
|
27
|
+
"jobId": input["jobId"],
|
|
28
|
+
"agentA": {"id": agent_a_id, "role": "requester"},
|
|
29
|
+
"agentB": {"id": agent_b_id, "role": "worker"},
|
|
30
|
+
"task": input["task"],
|
|
31
|
+
"result": input["result"],
|
|
32
|
+
"settlement": input.get("settlement"),
|
|
33
|
+
"verification": input["verification"],
|
|
34
|
+
"dispute": {"status": "none", "windowClosesAt": ""},
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inamprotocol
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Reference Python SDK for the INAM Protocol — agent identity, execution receipts, jobs, and reputation
|
|
5
|
+
Author: INAM Protocol
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://inamprotocol.org
|
|
8
|
+
Project-URL: Documentation, https://docs.inamprotocol.org
|
|
9
|
+
Project-URL: Repository, https://github.com/inamprotocol/inam-protocol
|
|
10
|
+
Project-URL: Specification, https://docs.inamprotocol.org/spec/
|
|
11
|
+
Project-URL: Changelog, https://github.com/inamprotocol/inam-protocol/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: inam,agent,ai-agent,identity,reputation,did,protocol
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: cryptography>=42.0.0
|
|
23
|
+
Requires-Dist: base58>=2.1.0
|
|
24
|
+
|
|
25
|
+
# inamprotocol (Python SDK)
|
|
26
|
+
|
|
27
|
+
Reference Python client for the INAM Protocol — parity with the TypeScript `InamClient` in `../src/sdk/client.ts`. See [`../SPEC.md`](../SPEC.md) for the full protocol specification.
|
|
28
|
+
|
|
29
|
+
## Install (development)
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
python -m venv .venv
|
|
33
|
+
./.venv/Scripts/python.exe -m pip install -e . pytest # Windows
|
|
34
|
+
# ./.venv/bin/python -m pip install -e . pytest # macOS/Linux
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Test
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
./.venv/Scripts/python.exe -m pytest -v
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`tests/test_interop.py` is the important one: it checks this SDK's `did:key` encoding, canonical JSON, and Ed25519 signing against fixed values generated once by the TypeScript reference implementation (`../scripts/interop-vectors.ts`). If a change here ever breaks that test, a receipt signed by this SDK would stop verifying against a registry or another SDK written in a different language — that's the whole point of the test.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from inamprotocol import InamClient, generate_keypair
|
|
49
|
+
|
|
50
|
+
keypair = generate_keypair()
|
|
51
|
+
client = InamClient("http://localhost:4021", keypair)
|
|
52
|
+
|
|
53
|
+
profile = client.register_agent(["document-extraction"], {"name": "My Agent"})
|
|
54
|
+
print(profile["id"]) # did:key:z...
|
|
55
|
+
|
|
56
|
+
reputation = client.get_reputation(profile["id"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
See `examples/interop_worker.py` for a full worker-side flow (register, link an external identity, submit signed Execution Receipt drafts), and `../scripts/run-interop-demo.sh` for the end-to-end cross-language demo (a TypeScript requester and this Python worker doing real business through the same live registry).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
inamprotocol/__init__.py
|
|
4
|
+
inamprotocol/canonical.py
|
|
5
|
+
inamprotocol/client.py
|
|
6
|
+
inamprotocol/keys.py
|
|
7
|
+
inamprotocol/receipt.py
|
|
8
|
+
inamprotocol.egg-info/PKG-INFO
|
|
9
|
+
inamprotocol.egg-info/SOURCES.txt
|
|
10
|
+
inamprotocol.egg-info/dependency_links.txt
|
|
11
|
+
inamprotocol.egg-info/requires.txt
|
|
12
|
+
inamprotocol.egg-info/top_level.txt
|
|
13
|
+
tests/test_canonical.py
|
|
14
|
+
tests/test_interop.py
|
|
15
|
+
tests/test_keys.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inamprotocol
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "inamprotocol"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Reference Python SDK for the INAM Protocol — agent identity, execution receipts, jobs, and reputation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "INAM Protocol" }]
|
|
13
|
+
keywords = ["inam", "agent", "ai-agent", "identity", "reputation", "did", "protocol"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"cryptography>=42.0.0",
|
|
25
|
+
"base58>=2.1.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://inamprotocol.org"
|
|
30
|
+
Documentation = "https://docs.inamprotocol.org"
|
|
31
|
+
Repository = "https://github.com/inamprotocol/inam-protocol"
|
|
32
|
+
"Specification" = "https://docs.inamprotocol.org/spec/"
|
|
33
|
+
Changelog = "https://github.com/inamprotocol/inam-protocol/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
include = ["inamprotocol*"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from inamprotocol.canonical import canonicalize
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_key_order_independence():
|
|
5
|
+
a = canonicalize({"b": 1, "a": 2, "c": {"z": 1, "y": 2}})
|
|
6
|
+
b = canonicalize({"c": {"y": 2, "z": 1}, "a": 2, "b": 1})
|
|
7
|
+
assert a == b
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_drops_none_valued_keys():
|
|
11
|
+
assert canonicalize({"a": 1, "b": None}) == '{"a":1}'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_preserves_array_order():
|
|
15
|
+
assert canonicalize([3, 1, 2]) == "[3,1,2]"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_sensitive_to_value_change():
|
|
19
|
+
assert canonicalize({"amount": "12.50"}) != canonicalize({"amount": "12.51"})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Cross-language correctness: these expected values were generated once by the
|
|
2
|
+
TypeScript reference implementation (see ../../scripts/interop-vectors.ts) from
|
|
3
|
+
a fixed test-only private key. If the Python SDK's did:key encoding,
|
|
4
|
+
canonical-JSON serialization, or Ed25519 signing ever diverges from the
|
|
5
|
+
TypeScript side, this test fails — that divergence is exactly what would make
|
|
6
|
+
a receipt signed by one language's SDK fail to verify against a registry (or
|
|
7
|
+
another SDK) written in the other language.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from inamprotocol.canonical import canonicalize
|
|
11
|
+
from inamprotocol.keys import keypair_from_raw_private_key, sign, to_base64, verify
|
|
12
|
+
|
|
13
|
+
# --- Ground truth from `npx tsx scripts/interop-vectors.ts` ---
|
|
14
|
+
PRIVATE_KEY_HEX = "01" * 32
|
|
15
|
+
EXPECTED_PUBLIC_KEY_HEX = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c"
|
|
16
|
+
EXPECTED_DID = "did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX"
|
|
17
|
+
EXPECTED_CANONICAL = (
|
|
18
|
+
'{"agentA":{"id":"did:key:zExampleA","role":"requester"},'
|
|
19
|
+
'"agentB":{"id":"did:key:zExampleB","role":"worker"},'
|
|
20
|
+
'"jobId":"job_interop_1",'
|
|
21
|
+
'"result":{"completedAt":"2026-08-22T00:01:00.000Z","outputHash":"sha256:out"},'
|
|
22
|
+
'"settlement":{"amount":"12.50","currency":"USDC"},'
|
|
23
|
+
'"task":{"capability":"translation.tr-en","createdAt":"2026-08-22T00:00:00.000Z","specHash":"sha256:spec"},'
|
|
24
|
+
'"verification":{"method":"payer_confirmation","outcome":"success"}}'
|
|
25
|
+
)
|
|
26
|
+
MESSAGE = b"inam-interop-test-message"
|
|
27
|
+
EXPECTED_SIGNATURE_B64 = "V3nIJGvwXXN+dNRM5gxyLeECY3fMVLu8baci/JrlArgeElTU3bEThOR7ipfJxGtHvWDIcrEzyIDq/vNkHVAJDA=="
|
|
28
|
+
|
|
29
|
+
SAMPLE_OBJECT = {
|
|
30
|
+
"jobId": "job_interop_1",
|
|
31
|
+
"agentA": {"id": "did:key:zExampleA", "role": "requester"},
|
|
32
|
+
"agentB": {"id": "did:key:zExampleB", "role": "worker"},
|
|
33
|
+
"task": {"capability": "translation.tr-en", "specHash": "sha256:spec", "createdAt": "2026-08-22T00:00:00.000Z"},
|
|
34
|
+
"result": {"outputHash": "sha256:out", "completedAt": "2026-08-22T00:01:00.000Z"},
|
|
35
|
+
"settlement": {"amount": "12.50", "currency": "USDC"},
|
|
36
|
+
"verification": {"method": "payer_confirmation", "outcome": "success"},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_did_key_matches_typescript():
|
|
41
|
+
kp = keypair_from_raw_private_key(bytes.fromhex(PRIVATE_KEY_HEX))
|
|
42
|
+
assert kp.public_key.hex() == EXPECTED_PUBLIC_KEY_HEX
|
|
43
|
+
assert kp.did == EXPECTED_DID
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_canonical_json_matches_typescript():
|
|
47
|
+
assert canonicalize(SAMPLE_OBJECT) == EXPECTED_CANONICAL
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_signature_is_byte_identical_to_typescript():
|
|
51
|
+
# Ed25519 signatures are deterministic (RFC 8032) — the same key and
|
|
52
|
+
# message MUST produce the exact same signature in any correct
|
|
53
|
+
# implementation, not just a signature that happens to verify.
|
|
54
|
+
kp = keypair_from_raw_private_key(bytes.fromhex(PRIVATE_KEY_HEX))
|
|
55
|
+
signature = sign(MESSAGE, kp.private_key)
|
|
56
|
+
assert to_base64(signature) == EXPECTED_SIGNATURE_B64
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_python_verifies_a_typescript_produced_signature():
|
|
60
|
+
import base64
|
|
61
|
+
|
|
62
|
+
kp_did = EXPECTED_DID
|
|
63
|
+
signature = base64.b64decode(EXPECTED_SIGNATURE_B64)
|
|
64
|
+
assert verify(signature, MESSAGE, kp_did) is True
|
|
65
|
+
assert verify(signature, b"tampered message", kp_did) is False
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from inamprotocol.keys import generate_keypair, did_to_public_key, sign, verify
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_did_key_round_trip():
|
|
5
|
+
kp = generate_keypair()
|
|
6
|
+
assert kp.did.startswith("did:key:z")
|
|
7
|
+
assert did_to_public_key(kp.did) == kp.public_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_sign_and_verify():
|
|
11
|
+
kp = generate_keypair()
|
|
12
|
+
message = b"hello inam"
|
|
13
|
+
signature = sign(message, kp.private_key)
|
|
14
|
+
assert verify(signature, message, kp.did) is True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_rejects_tampered_message():
|
|
18
|
+
kp = generate_keypair()
|
|
19
|
+
signature = sign(b"original", kp.private_key)
|
|
20
|
+
assert verify(signature, b"tampered", kp.did) is False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_rejects_wrong_signer():
|
|
24
|
+
signer = generate_keypair()
|
|
25
|
+
impostor = generate_keypair()
|
|
26
|
+
message = b"hello inam"
|
|
27
|
+
signature = sign(message, signer.private_key)
|
|
28
|
+
assert verify(signature, message, impostor.did) is False
|