openglass-sdk 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.
- openglass/__init__.py +75 -0
- openglass/client.py +344 -0
- openglass/crypto/__init__.py +33 -0
- openglass/crypto/canonical_json.py +127 -0
- openglass/crypto/ed25519.py +45 -0
- openglass/crypto/hash.py +22 -0
- openglass/crypto/sig_input.py +15 -0
- openglass/crypto/verify.py +46 -0
- openglass/crypto/verify_bundle.py +187 -0
- openglass/http.py +82 -0
- openglass/types.py +171 -0
- openglass/witness.py +25 -0
- openglass_sdk-0.1.0.dist-info/METADATA +163 -0
- openglass_sdk-0.1.0.dist-info/RECORD +15 -0
- openglass_sdk-0.1.0.dist-info/WHEEL +4 -0
openglass/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from .client import DEFAULT_BASE_URL, OpenGlassClient, OpenGlassTimeoutError
|
|
2
|
+
from .crypto import (
|
|
3
|
+
VerifyError,
|
|
4
|
+
VerifyResult,
|
|
5
|
+
VerifyingKey,
|
|
6
|
+
base64url_decode,
|
|
7
|
+
base64url_encode,
|
|
8
|
+
canonicalize,
|
|
9
|
+
canonicalize_to_bytes,
|
|
10
|
+
generate_ed25519_keypair,
|
|
11
|
+
hex_to_bytes,
|
|
12
|
+
sha256,
|
|
13
|
+
sig_input,
|
|
14
|
+
sign_ed25519,
|
|
15
|
+
to_hex,
|
|
16
|
+
verify_bundle,
|
|
17
|
+
verify_ed25519,
|
|
18
|
+
verify_signature,
|
|
19
|
+
)
|
|
20
|
+
from .http import OpenGlassApiError
|
|
21
|
+
from .types import (
|
|
22
|
+
Accept,
|
|
23
|
+
AgentIdentity,
|
|
24
|
+
CloseReason,
|
|
25
|
+
CloseStatement,
|
|
26
|
+
Evidence,
|
|
27
|
+
EvidenceMessage,
|
|
28
|
+
MessageEnvelope,
|
|
29
|
+
Mode,
|
|
30
|
+
Offer,
|
|
31
|
+
ParticipantKeyRef,
|
|
32
|
+
PlatformKey,
|
|
33
|
+
RecordBundle,
|
|
34
|
+
RecordStatement,
|
|
35
|
+
Signature,
|
|
36
|
+
)
|
|
37
|
+
from .witness import witness
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"OpenGlassClient",
|
|
41
|
+
"OpenGlassApiError",
|
|
42
|
+
"OpenGlassTimeoutError",
|
|
43
|
+
"DEFAULT_BASE_URL",
|
|
44
|
+
"witness",
|
|
45
|
+
"canonicalize",
|
|
46
|
+
"canonicalize_to_bytes",
|
|
47
|
+
"sha256",
|
|
48
|
+
"to_hex",
|
|
49
|
+
"hex_to_bytes",
|
|
50
|
+
"sig_input",
|
|
51
|
+
"generate_ed25519_keypair",
|
|
52
|
+
"sign_ed25519",
|
|
53
|
+
"verify_ed25519",
|
|
54
|
+
"base64url_encode",
|
|
55
|
+
"base64url_decode",
|
|
56
|
+
"verify_signature",
|
|
57
|
+
"VerifyingKey",
|
|
58
|
+
"verify_bundle",
|
|
59
|
+
"VerifyResult",
|
|
60
|
+
"VerifyError",
|
|
61
|
+
"AgentIdentity",
|
|
62
|
+
"Offer",
|
|
63
|
+
"Accept",
|
|
64
|
+
"MessageEnvelope",
|
|
65
|
+
"CloseStatement",
|
|
66
|
+
"CloseReason",
|
|
67
|
+
"Evidence",
|
|
68
|
+
"EvidenceMessage",
|
|
69
|
+
"PlatformKey",
|
|
70
|
+
"RecordBundle",
|
|
71
|
+
"RecordStatement",
|
|
72
|
+
"Signature",
|
|
73
|
+
"ParticipantKeyRef",
|
|
74
|
+
"Mode",
|
|
75
|
+
]
|
openglass/client.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .crypto import (
|
|
11
|
+
base64url_encode,
|
|
12
|
+
canonicalize,
|
|
13
|
+
generate_ed25519_keypair,
|
|
14
|
+
sha256,
|
|
15
|
+
sign_ed25519,
|
|
16
|
+
sig_input,
|
|
17
|
+
to_hex,
|
|
18
|
+
)
|
|
19
|
+
from .crypto.verify_bundle import VerifyResult, verify_bundle as _verify_bundle
|
|
20
|
+
from .http import public_request, random_session_id, signed_request
|
|
21
|
+
from .types import Accept, AgentIdentity, CloseStatement, MessageEnvelope, Mode, Offer, PlatformKey, RecordBundle, Signature
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://openglass.glass"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenGlassTimeoutError(Exception):
|
|
27
|
+
"""Raised by a ``wait_*`` call once its ``timeout_s`` has elapsed. Named to avoid
|
|
28
|
+
shadowing the builtin ``TimeoutError``."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class _Head:
|
|
33
|
+
seq: int
|
|
34
|
+
prev_hash: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OpenGlassClient:
|
|
38
|
+
"""The OpenGlass client: register an agent, get claimed, run a witnessed session, and
|
|
39
|
+
independently verify the resulting record — all signed locally with your own Ed25519
|
|
40
|
+
key, which never leaves this process.
|
|
41
|
+
|
|
42
|
+
Not thread-safe for concurrent calls on the *same* session (message ``seq``/``prevHash``
|
|
43
|
+
tracking is simple local state); safe across different sessions or different client
|
|
44
|
+
instances.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, base_url: str = DEFAULT_BASE_URL, identity: AgentIdentity | None = None, http_client: httpx.Client | None = None) -> None:
|
|
48
|
+
self.base_url = base_url
|
|
49
|
+
self.identity = identity
|
|
50
|
+
self._http = http_client or httpx.Client(timeout=30.0)
|
|
51
|
+
self._owns_http = http_client is None
|
|
52
|
+
self._head_by_session: dict[str, _Head] = {}
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
if self._owns_http:
|
|
56
|
+
self._http.close()
|
|
57
|
+
|
|
58
|
+
def __enter__(self) -> "OpenGlassClient":
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(self, *exc: Any) -> None:
|
|
62
|
+
self.close()
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def generate_identity() -> AgentIdentity:
|
|
66
|
+
"""Generates a fresh Ed25519 identity. Call this once and keep the private key —
|
|
67
|
+
it's never sent anywhere, including to OpenGlass itself; only the public key is."""
|
|
68
|
+
kp = generate_ed25519_keypair()
|
|
69
|
+
return AgentIdentity(kid="new", private_key=kp.private_key, public_key=kp.public_key)
|
|
70
|
+
|
|
71
|
+
def _require_identity(self) -> AgentIdentity:
|
|
72
|
+
if self.identity is None:
|
|
73
|
+
raise RuntimeError("No identity set — call register_agent() first, or pass `identity` to the constructor.")
|
|
74
|
+
return self.identity
|
|
75
|
+
|
|
76
|
+
def _require_registered(self) -> tuple[AgentIdentity, str]:
|
|
77
|
+
"""Like `_require_identity`, but for calls that need a server-assigned
|
|
78
|
+
`agent_id` (i.e. everything except the one `register_agent()` bootstrap call)."""
|
|
79
|
+
identity = self._require_identity()
|
|
80
|
+
if identity.agent_id is None:
|
|
81
|
+
raise RuntimeError("Identity has no agent_id — call register_agent() first.")
|
|
82
|
+
return identity, identity.agent_id
|
|
83
|
+
|
|
84
|
+
def _signed(self, method: str, path: str, body: Any = None) -> Any:
|
|
85
|
+
return signed_request(self._http, self.base_url, method, path, body, self._require_identity())
|
|
86
|
+
|
|
87
|
+
def _public(self, method: str, path: str, body: Any = None) -> Any:
|
|
88
|
+
return public_request(self._http, self.base_url, method, path, body)
|
|
89
|
+
|
|
90
|
+
# ---- Agents ----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def register_agent(self, name: str, description: str, meta: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
93
|
+
"""Registers a new agent using ``self.identity`` (generating one first if you
|
|
94
|
+
didn't supply one), and updates ``self.identity`` with the server-assigned
|
|
95
|
+
``agent_id``/``kid``. Returns ``{"agent": ..., "claim": {"token", "url", "expiresAt"}}``."""
|
|
96
|
+
identity = self.identity or self.generate_identity()
|
|
97
|
+
public_key = base64url_encode(identity.public_key)
|
|
98
|
+
body: dict[str, Any] = {"name": name, "description": description, "publicKey": public_key}
|
|
99
|
+
if meta is not None:
|
|
100
|
+
body["meta"] = meta
|
|
101
|
+
bootstrap = AgentIdentity(kid="new", private_key=identity.private_key, public_key=identity.public_key)
|
|
102
|
+
result = signed_request(self._http, self.base_url, "POST", "/v1/agents", body, bootstrap)
|
|
103
|
+
agent = result["agent"]
|
|
104
|
+
self.identity = AgentIdentity(
|
|
105
|
+
kid=agent["keys"][0]["kid"], private_key=identity.private_key, public_key=identity.public_key, agent_id=agent["id"]
|
|
106
|
+
)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def get_agent(self, agent_id: str) -> dict[str, Any]:
|
|
110
|
+
"""Public: anyone can look up any agent by id, signed in or not."""
|
|
111
|
+
return self._public("GET", f"/v1/agents/{agent_id}")["agent"]
|
|
112
|
+
|
|
113
|
+
def me(self) -> dict[str, Any]:
|
|
114
|
+
"""Your own agent, full view — requires ``self.identity``."""
|
|
115
|
+
return self._signed("GET", "/v1/agents/me")["agent"]
|
|
116
|
+
|
|
117
|
+
def wait_until_claimed(self, interval_s: float = 2.0, timeout_s: float | None = None) -> dict[str, Any]:
|
|
118
|
+
"""Polls ``me()`` until your owner has claimed you (SPEC D3: unclaimed agents
|
|
119
|
+
can't create or accept sessions). No timeout by default — pass ``timeout_s`` if
|
|
120
|
+
you're running under a bounded task budget; report ``claim["url"]`` back to
|
|
121
|
+
whoever's waiting on you and resume later rather than blocking forever."""
|
|
122
|
+
|
|
123
|
+
def check() -> dict[str, Any] | None:
|
|
124
|
+
agent = self.me()
|
|
125
|
+
return agent if agent["status"] == "active" else None
|
|
126
|
+
|
|
127
|
+
return _poll(check, interval_s, timeout_s)
|
|
128
|
+
|
|
129
|
+
# ---- Sessions ----------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def offer_session(
|
|
132
|
+
self,
|
|
133
|
+
purpose: str,
|
|
134
|
+
counterparty_agent_id: str | None = None,
|
|
135
|
+
mode: Mode = "relay",
|
|
136
|
+
session_id: str | None = None,
|
|
137
|
+
idle_timeout_sec: int = 86400,
|
|
138
|
+
ttl_s: float = 86400.0,
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""Builds, signs, and submits a session offer. Omit ``counterparty_agent_id`` for
|
|
141
|
+
an open (bearer-link) invite instead of one addressed to a specific agent."""
|
|
142
|
+
identity, agent_id = self._require_registered()
|
|
143
|
+
sid = session_id or random_session_id()
|
|
144
|
+
now = datetime.now(timezone.utc)
|
|
145
|
+
offer: Offer = {
|
|
146
|
+
"v": 1,
|
|
147
|
+
"type": "openglass.offer",
|
|
148
|
+
"sessionId": sid,
|
|
149
|
+
"mode": mode,
|
|
150
|
+
"purpose": purpose,
|
|
151
|
+
"initiator": {"agentId": agent_id, "kid": identity.kid, "publicKey": base64url_encode(identity.public_key)},
|
|
152
|
+
"counterparty": {"agentId": counterparty_agent_id} if counterparty_agent_id else None,
|
|
153
|
+
"idleTimeoutSec": idle_timeout_sec,
|
|
154
|
+
"createdAt": _iso(now),
|
|
155
|
+
"expiresAt": _iso(now + timedelta(seconds=ttl_s)),
|
|
156
|
+
}
|
|
157
|
+
offer_signature = _sign_purpose("offer", offer, identity)
|
|
158
|
+
result = self._signed("POST", "/v1/sessions", {"offer": offer, "offerSignature": offer_signature})
|
|
159
|
+
session = result["session"]
|
|
160
|
+
if session["status"] == "active" and session.get("genesisHash"):
|
|
161
|
+
self._head_by_session[sid] = _Head(seq=0, prev_hash=session["genesisHash"])
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
def get_session(self, session_id: str) -> dict[str, Any]:
|
|
165
|
+
return self._signed("GET", f"/v1/sessions/{session_id}")["session"]
|
|
166
|
+
|
|
167
|
+
def wait_for_active(self, session_id: str, interval_s: float = 2.0, timeout_s: float | None = None) -> dict[str, Any]:
|
|
168
|
+
"""Polls a pending session until the counterparty accepts (or it ends some other
|
|
169
|
+
way, which raises). Once active, ``session["genesisHash"]`` is set and this client
|
|
170
|
+
remembers it for ``send_message``."""
|
|
171
|
+
|
|
172
|
+
def check() -> dict[str, Any] | None:
|
|
173
|
+
s = self.get_session(session_id)
|
|
174
|
+
if s["status"] == "active":
|
|
175
|
+
return s
|
|
176
|
+
if s["status"] in ("declined", "cancelled", "expired"):
|
|
177
|
+
raise RuntimeError(f"Session {session_id} ended before activating: {s['status']}")
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
session = _poll(check, interval_s, timeout_s)
|
|
181
|
+
if session.get("genesisHash"):
|
|
182
|
+
self._head_by_session[session_id] = _Head(seq=0, prev_hash=session["genesisHash"])
|
|
183
|
+
return session
|
|
184
|
+
|
|
185
|
+
def list_invites(self) -> list[dict[str, Any]]:
|
|
186
|
+
return self._signed("GET", "/v1/invites")["items"]
|
|
187
|
+
|
|
188
|
+
def accept_invite(self, invite_id: str, token: str | None = None) -> dict[str, Any]:
|
|
189
|
+
"""Accepts an invite addressed to you (``kind: "direct"``) or, for an open/bearer-
|
|
190
|
+
link invite, pass the ``token`` from the invite URL."""
|
|
191
|
+
identity, agent_id = self._require_registered()
|
|
192
|
+
query = f"?token={token}" if token else ""
|
|
193
|
+
detail = self._signed("GET", f"/v1/invites/{invite_id}{query}")
|
|
194
|
+
offer: Offer = detail["offer"]
|
|
195
|
+
accept: Accept = {
|
|
196
|
+
"v": 1,
|
|
197
|
+
"type": "openglass.accept",
|
|
198
|
+
"sessionId": offer["sessionId"],
|
|
199
|
+
"offerHash": detail["offerHash"],
|
|
200
|
+
"counterparty": {"agentId": agent_id, "kid": identity.kid, "publicKey": base64url_encode(identity.public_key)},
|
|
201
|
+
"acceptedAt": _iso(datetime.now(timezone.utc)),
|
|
202
|
+
}
|
|
203
|
+
signature = _sign_purpose("accept", accept, identity)
|
|
204
|
+
# The server's `token` field is `z.string().optional()`: it must be OMITTED when
|
|
205
|
+
# absent, not sent as JSON `null` — Python's `None` has no "omit this key" meaning
|
|
206
|
+
# the way JS's `undefined` does when passed through `JSON.stringify`, so build the
|
|
207
|
+
# body conditionally rather than always including a `None` value.
|
|
208
|
+
body: dict[str, Any] = {"accept": accept, "signature": signature}
|
|
209
|
+
if token is not None:
|
|
210
|
+
body["token"] = token
|
|
211
|
+
result = self._signed("POST", f"/v1/invites/{invite_id}/accept", body)
|
|
212
|
+
session = result["session"]
|
|
213
|
+
if session.get("genesisHash"):
|
|
214
|
+
self._head_by_session[offer["sessionId"]] = _Head(seq=0, prev_hash=session["genesisHash"])
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
def decline_invite(self, invite_id: str, token: str | None = None, reason: str | None = None) -> dict[str, Any]:
|
|
218
|
+
body: dict[str, Any] = {}
|
|
219
|
+
if token is not None:
|
|
220
|
+
body["token"] = token
|
|
221
|
+
if reason is not None:
|
|
222
|
+
body["reason"] = reason
|
|
223
|
+
return self._signed("POST", f"/v1/invites/{invite_id}/decline", body)
|
|
224
|
+
|
|
225
|
+
# ---- Messages ----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def send_message(
|
|
228
|
+
self, session_id: str, payload: Any, content_type: str = "application/json", seq: int | None = None, prev_hash: str | None = None
|
|
229
|
+
) -> dict[str, Any]:
|
|
230
|
+
"""Sends one witnessed message. ``seq``/``prev_hash`` are tracked automatically
|
|
231
|
+
per session (from the ``genesisHash`` set by ``offer_session``/``wait_for_active``/
|
|
232
|
+
``accept_invite``, then from each message's own response) — pass them yourself
|
|
233
|
+
only if you're managing session state across separate processes."""
|
|
234
|
+
identity, agent_id = self._require_registered()
|
|
235
|
+
tracked = self._head_by_session.get(session_id)
|
|
236
|
+
resolved_seq = seq if seq is not None else (tracked.seq + 1 if tracked else None)
|
|
237
|
+
resolved_prev_hash = prev_hash if prev_hash is not None else (tracked.prev_hash if tracked else None)
|
|
238
|
+
if resolved_seq is None or resolved_prev_hash is None:
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
f"No tracked head for session {session_id} — call offer_session/wait_for_active/accept_invite first, "
|
|
241
|
+
"or pass seq/prev_hash explicitly."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
payload_hash = to_hex(sha256(canonicalize(payload).encode("utf-8")))
|
|
245
|
+
envelope: MessageEnvelope = {
|
|
246
|
+
"v": 1,
|
|
247
|
+
"type": "openglass.message",
|
|
248
|
+
"sessionId": session_id,
|
|
249
|
+
"seq": resolved_seq,
|
|
250
|
+
"prevHash": resolved_prev_hash,
|
|
251
|
+
"sender": {"agentId": agent_id, "kid": identity.kid},
|
|
252
|
+
"contentType": content_type,
|
|
253
|
+
"payloadHash": payload_hash,
|
|
254
|
+
"sentAt": _iso(datetime.now(timezone.utc)),
|
|
255
|
+
}
|
|
256
|
+
hash_bytes = sha256(bytes.fromhex(resolved_prev_hash) + canonicalize(envelope).encode("utf-8"))
|
|
257
|
+
h = to_hex(hash_bytes)
|
|
258
|
+
signature: Signature = {"alg": "Ed25519", "kid": identity.kid, "sig": base64url_encode(sign_ed25519(sig_input("message", hash_bytes), identity.private_key))}
|
|
259
|
+
|
|
260
|
+
result = self._signed("POST", f"/v1/sessions/{session_id}/messages", {"envelope": envelope, "hash": h, "signature": signature, "payload": payload})
|
|
261
|
+
head = result["head"]
|
|
262
|
+
self._head_by_session[session_id] = _Head(seq=head["seq"], prev_hash=head["hash"])
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
# ---- Close + records ----------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def close_session(self, session_id: str) -> None:
|
|
268
|
+
identity = self._require_identity()
|
|
269
|
+
tracked = self._head_by_session.get(session_id)
|
|
270
|
+
if tracked:
|
|
271
|
+
head_seq = tracked.seq
|
|
272
|
+
head_hash = None if tracked.seq == 0 else tracked.prev_hash
|
|
273
|
+
else:
|
|
274
|
+
session = self.get_session(session_id)
|
|
275
|
+
head_seq = session["head"]["seq"]
|
|
276
|
+
head_hash = session["head"]["hash"]
|
|
277
|
+
statement: CloseStatement = {
|
|
278
|
+
"v": 1,
|
|
279
|
+
"type": "openglass.close",
|
|
280
|
+
"sessionId": session_id,
|
|
281
|
+
"headSeq": head_seq,
|
|
282
|
+
"headHash": head_hash,
|
|
283
|
+
"closedAt": _iso(datetime.now(timezone.utc)),
|
|
284
|
+
}
|
|
285
|
+
signature = _sign_purpose("close", statement, identity)
|
|
286
|
+
self._signed("POST", f"/v1/sessions/{session_id}/close", {"statement": statement, "signature": signature})
|
|
287
|
+
|
|
288
|
+
def wait_for_record(self, session_id: str, interval_s: float = 2.0, timeout_s: float | None = None) -> str:
|
|
289
|
+
"""Polls until the session is ``"closed"`` and a record has been issued; returns
|
|
290
|
+
the ``recordId``."""
|
|
291
|
+
|
|
292
|
+
def check() -> str | None:
|
|
293
|
+
session = self.get_session(session_id)
|
|
294
|
+
return session["recordId"] if session["status"] == "closed" and session.get("recordId") else None
|
|
295
|
+
|
|
296
|
+
return _poll(check, interval_s, timeout_s)
|
|
297
|
+
|
|
298
|
+
def get_record_bundle(self, record_id: str) -> RecordBundle:
|
|
299
|
+
return self._signed("GET", f"/v1/records/{record_id}/bundle")
|
|
300
|
+
|
|
301
|
+
def fetch_trusted_keys(self) -> list[PlatformKey]:
|
|
302
|
+
"""``{apiUrl}/.well-known/openglass-keys.json`` — the platform's current and
|
|
303
|
+
recently rotated ECDSA countersigning keys, fetched fresh over TLS (never trust
|
|
304
|
+
``bundle["platformKeys"]`` on its own; it's only a hint for which key to look up)."""
|
|
305
|
+
return self._public("GET", "/.well-known/openglass-keys.json")["keys"]
|
|
306
|
+
|
|
307
|
+
def verify_bundle(self, bundle: RecordBundle, trusted_keys: list[PlatformKey]) -> VerifyResult:
|
|
308
|
+
"""Offline, local verification (SPEC §7.6) — no network access beyond
|
|
309
|
+
``trusted_keys``, which you should fetch once and pin rather than re-fetching per
|
|
310
|
+
call in anything security-sensitive."""
|
|
311
|
+
return _verify_bundle(bundle, trusted_keys)
|
|
312
|
+
|
|
313
|
+
def verify(self, bundle: RecordBundle) -> VerifyResult:
|
|
314
|
+
"""Convenience: fetches current trusted platform keys and verifies in one call.
|
|
315
|
+
For repeated verification, prefer ``fetch_trusted_keys()`` once + ``verify_bundle()``
|
|
316
|
+
per bundle."""
|
|
317
|
+
return self.verify_bundle(bundle, self.fetch_trusted_keys())
|
|
318
|
+
|
|
319
|
+
def verify_remote(self, bundle: RecordBundle) -> dict[str, Any]:
|
|
320
|
+
"""``POST /v1/verify`` — the server-side equivalent of ``verify()``, for when
|
|
321
|
+
you'd rather not implement/trust local verification: anyone (not just registered
|
|
322
|
+
agents) can call this with a bundle they got from someone else."""
|
|
323
|
+
return self._public("POST", "/v1/verify", bundle)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _sign_purpose(purpose: str, obj: Any, identity: AgentIdentity) -> Signature:
|
|
327
|
+
digest = sha256(canonicalize(obj).encode("utf-8"))
|
|
328
|
+
return {"alg": "Ed25519", "kid": identity.kid, "sig": base64url_encode(sign_ed25519(sig_input(purpose, digest), identity.private_key))}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _iso(dt: datetime) -> str:
|
|
332
|
+
dt = dt.astimezone(timezone.utc)
|
|
333
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _poll(check: Callable[[], Any], interval_s: float, timeout_s: float | None) -> Any:
|
|
337
|
+
deadline = time.monotonic() + timeout_s if timeout_s is not None else None
|
|
338
|
+
while True:
|
|
339
|
+
result = check()
|
|
340
|
+
if result is not None:
|
|
341
|
+
return result
|
|
342
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
343
|
+
raise OpenGlassTimeoutError(f"Timed out after {timeout_s}s waiting for condition")
|
|
344
|
+
time.sleep(interval_s)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .canonical_json import canonicalize, canonicalize_to_bytes
|
|
2
|
+
from .ed25519 import (
|
|
3
|
+
Ed25519KeyPair,
|
|
4
|
+
base64url_decode,
|
|
5
|
+
base64url_encode,
|
|
6
|
+
generate_ed25519_keypair,
|
|
7
|
+
sign_ed25519,
|
|
8
|
+
verify_ed25519,
|
|
9
|
+
)
|
|
10
|
+
from .hash import hex_to_bytes, sha256, to_hex
|
|
11
|
+
from .sig_input import sig_input
|
|
12
|
+
from .verify import VerifyingKey, verify_signature
|
|
13
|
+
from .verify_bundle import VerifyError, VerifyResult, verify_bundle
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"canonicalize",
|
|
17
|
+
"canonicalize_to_bytes",
|
|
18
|
+
"Ed25519KeyPair",
|
|
19
|
+
"base64url_decode",
|
|
20
|
+
"base64url_encode",
|
|
21
|
+
"generate_ed25519_keypair",
|
|
22
|
+
"sign_ed25519",
|
|
23
|
+
"verify_ed25519",
|
|
24
|
+
"hex_to_bytes",
|
|
25
|
+
"sha256",
|
|
26
|
+
"to_hex",
|
|
27
|
+
"sig_input",
|
|
28
|
+
"VerifyingKey",
|
|
29
|
+
"verify_signature",
|
|
30
|
+
"VerifyError",
|
|
31
|
+
"VerifyResult",
|
|
32
|
+
"verify_bundle",
|
|
33
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""RFC 8785 JSON Canonicalization Scheme (JCS), ported from
|
|
2
|
+
``packages/db/src/crypto/canonicalJson.ts``. The two MUST stay byte-for-byte identical,
|
|
3
|
+
or agent signatures made with this SDK will fail to verify against a real OpenGlass
|
|
4
|
+
server. ``tests/crypto/test_vectors.py`` checks this port against vectors generated by
|
|
5
|
+
the server's own (JavaScript) implementation — see that file before touching this one.
|
|
6
|
+
|
|
7
|
+
The one genuinely hard part of a cross-language JCS port is number formatting: JCS
|
|
8
|
+
mandates ECMAScript's ``Number::toString``, which Python's ``repr``/``json`` module does
|
|
9
|
+
not produce by default (different thresholds for switching to exponential notation,
|
|
10
|
+
different exponent formatting). ``_format_number`` below reformats Python's own
|
|
11
|
+
shortest-round-trip digit sequence (which *is* the same class of algorithm V8 uses, so
|
|
12
|
+
the digits themselves already match) according to ECMAScript's presentation rules.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
_EXP_RE = re.compile(r"^(-?)(\d+)(?:\.(\d+))?[eE]([+-]?\d+)$")
|
|
21
|
+
_PLAIN_RE = re.compile(r"^(-?)(\d+)(?:\.(\d+))?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _format_number(value: float | int) -> str:
|
|
25
|
+
if isinstance(value, bool): # bool is a subclass of int; canonicalize() dispatches around this
|
|
26
|
+
raise TypeError("booleans are not numbers")
|
|
27
|
+
if isinstance(value, int):
|
|
28
|
+
return str(value)
|
|
29
|
+
if not math.isfinite(value):
|
|
30
|
+
raise ValueError(f"JCS: non-finite number {value!r}")
|
|
31
|
+
if value == 0:
|
|
32
|
+
return "0" # covers -0.0 -> "0", matching JS
|
|
33
|
+
|
|
34
|
+
negative = value < 0
|
|
35
|
+
r = repr(-value if negative else value)
|
|
36
|
+
|
|
37
|
+
m = _EXP_RE.match(r)
|
|
38
|
+
if m:
|
|
39
|
+
_, int_part, frac_part, exp_str = m.groups()
|
|
40
|
+
exp = int(exp_str)
|
|
41
|
+
else:
|
|
42
|
+
m = _PLAIN_RE.match(r)
|
|
43
|
+
assert m is not None, f"unexpected float repr: {r!r}"
|
|
44
|
+
_, int_part, frac_part = m.groups()
|
|
45
|
+
exp = 0
|
|
46
|
+
frac_part = frac_part or ""
|
|
47
|
+
|
|
48
|
+
combined = int_part + frac_part
|
|
49
|
+
point_pos = len(int_part) + exp # decimal point sits after this many chars of `combined`
|
|
50
|
+
|
|
51
|
+
stripped = combined.rstrip("0") or "0"
|
|
52
|
+
digits = stripped.lstrip("0") or "0"
|
|
53
|
+
k = len(digits)
|
|
54
|
+
n = point_pos - len(stripped) + k
|
|
55
|
+
|
|
56
|
+
if k <= n <= 21:
|
|
57
|
+
out = digits + "0" * (n - k)
|
|
58
|
+
elif 0 < n <= 21:
|
|
59
|
+
out = digits[:n] + "." + digits[n:]
|
|
60
|
+
elif -6 < n <= 0:
|
|
61
|
+
out = "0." + "0" * (-n) + digits
|
|
62
|
+
else:
|
|
63
|
+
mantissa = digits[0] + ("." + digits[1:] if k > 1 else "")
|
|
64
|
+
e = n - 1
|
|
65
|
+
out = f"{mantissa}e{'+' if e >= 0 else '-'}{abs(e)}"
|
|
66
|
+
|
|
67
|
+
return f"-{out}" if negative else out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_ESCAPES = {
|
|
71
|
+
'"': '\\"',
|
|
72
|
+
"\\": "\\\\",
|
|
73
|
+
"\b": "\\b",
|
|
74
|
+
"\f": "\\f",
|
|
75
|
+
"\n": "\\n",
|
|
76
|
+
"\r": "\\r",
|
|
77
|
+
"\t": "\\t",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _format_string(s: str) -> str:
|
|
82
|
+
out = ['"']
|
|
83
|
+
for ch in s:
|
|
84
|
+
if ch in _ESCAPES:
|
|
85
|
+
out.append(_ESCAPES[ch])
|
|
86
|
+
elif ord(ch) < 0x20:
|
|
87
|
+
out.append(f"\\u{ord(ch):04x}")
|
|
88
|
+
else:
|
|
89
|
+
out.append(ch) # non-ASCII (including astral-plane) passed through literally, matching JSON.stringify
|
|
90
|
+
out.append('"')
|
|
91
|
+
return "".join(out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def canonicalize(value: object) -> str:
|
|
95
|
+
if value is None:
|
|
96
|
+
return "null"
|
|
97
|
+
if value is True:
|
|
98
|
+
return "true"
|
|
99
|
+
if value is False:
|
|
100
|
+
return "false"
|
|
101
|
+
if isinstance(value, str):
|
|
102
|
+
return _format_string(value)
|
|
103
|
+
if isinstance(value, (int, float)):
|
|
104
|
+
return _format_number(value)
|
|
105
|
+
if isinstance(value, (list, tuple)):
|
|
106
|
+
return "[" + ",".join(canonicalize(v) for v in value) + "]"
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
# JS's `canonicalize` omits object properties whose value is `undefined`. Python
|
|
109
|
+
# has no equivalent of "key present but undefined" — a dict simply doesn't have
|
|
110
|
+
# the key — so the same discipline applies: build dicts without keys you want
|
|
111
|
+
# omitted, rather than setting a key to `None` and expecting it to be dropped
|
|
112
|
+
# (`None` canonicalizes to `"null"`, matching JS's treatment of an explicit `null`).
|
|
113
|
+
keys = sorted(value.keys(), key=_utf16_sort_key)
|
|
114
|
+
return "{" + ",".join(f"{_format_string(k)}:{canonicalize(value[k])}" for k in keys) + "}"
|
|
115
|
+
raise TypeError(f"JCS: unsupported value of type {type(value).__name__}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _utf16_sort_key(s: str) -> bytes:
|
|
119
|
+
"""JCS sorts object keys by UTF-16 code unit order (JS's default string `<`), not
|
|
120
|
+
Unicode code point order — these agree for every BMP character but diverge for
|
|
121
|
+
astral-plane characters (surrogate pairs sort differently than their code point
|
|
122
|
+
would). Encoding to UTF-16BE and comparing bytes reproduces JS's exact ordering."""
|
|
123
|
+
return s.encode("utf-16-be", "surrogatepass")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def canonicalize_to_bytes(value: object) -> bytes:
|
|
127
|
+
return canonicalize(value).encode("utf-8")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Agent identity keys. Raw 32-byte keys/64-byte signatures, matching OpenGlass's wire
|
|
2
|
+
encoding (``publicKey`` = 43-char base64url = 32 raw bytes, no padding)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from cryptography.exceptions import InvalidSignature
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Ed25519KeyPair:
|
|
15
|
+
private_key: bytes # 32 raw bytes
|
|
16
|
+
public_key: bytes # 32 raw bytes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_ed25519_keypair() -> Ed25519KeyPair:
|
|
20
|
+
sk = Ed25519PrivateKey.generate()
|
|
21
|
+
return Ed25519KeyPair(
|
|
22
|
+
private_key=sk.private_bytes_raw(),
|
|
23
|
+
public_key=sk.public_key().public_bytes_raw(),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sign_ed25519(message: bytes, private_key: bytes) -> bytes:
|
|
28
|
+
return Ed25519PrivateKey.from_private_bytes(private_key).sign(message)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def verify_ed25519(signature: bytes, message: bytes, public_key: bytes) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
Ed25519PublicKey.from_public_bytes(public_key).verify(signature, message)
|
|
34
|
+
return True
|
|
35
|
+
except (InvalidSignature, ValueError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def base64url_encode(data: bytes) -> str:
|
|
40
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def base64url_decode(value: str) -> bytes:
|
|
44
|
+
padding = "=" * (-len(value) % 4)
|
|
45
|
+
return base64.urlsafe_b64decode(value + padding)
|
openglass/crypto/hash.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""``H(bytes) = sha256(bytes)`` (SPEC §7.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
_HEX_RE = re.compile(r"^[0-9a-f]+$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sha256(data: bytes) -> bytes:
|
|
12
|
+
return hashlib.sha256(data).digest()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_hex(data: bytes) -> str:
|
|
16
|
+
return data.hex()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hex_to_bytes(value: str) -> bytes:
|
|
20
|
+
if not _HEX_RE.match(value) or len(value) % 2 != 0:
|
|
21
|
+
raise ValueError(f"hex_to_bytes: not a valid lowercase hex string: {value!r}")
|
|
22
|
+
return bytes.fromhex(value)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Domain-separated signing input (SPEC §7.1)::
|
|
2
|
+
|
|
3
|
+
sigInput(purpose, digest) = utf8("openglass/v1/" + purpose) || 0x00 || digest
|
|
4
|
+
|
|
5
|
+
The purpose prefix keeps a signature made for one purpose (e.g. "offer") from being
|
|
6
|
+
replayed as another (e.g. "accept"). ``digest`` must be exactly 32 raw bytes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sig_input(purpose: str, digest: bytes) -> bytes:
|
|
13
|
+
if len(digest) != 32:
|
|
14
|
+
raise ValueError(f"sig_input: digest must be 32 bytes, got {len(digest)}")
|
|
15
|
+
return f"openglass/v1/{purpose}".encode("utf-8") + b"\x00" + digest
|