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 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)
@@ -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