inter-agent-core 0.2.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.
Files changed (80) hide show
  1. inter_agent/__init__.py +0 -0
  2. inter_agent/core/__init__.py +1 -0
  3. inter_agent/core/adapter_control.py +350 -0
  4. inter_agent/core/auth.py +279 -0
  5. inter_agent/core/channels.py +131 -0
  6. inter_agent/core/client.py +345 -0
  7. inter_agent/core/config.py +325 -0
  8. inter_agent/core/errors.py +31 -0
  9. inter_agent/core/kick.py +111 -0
  10. inter_agent/core/list.py +127 -0
  11. inter_agent/core/publish.py +146 -0
  12. inter_agent/core/router.py +8 -0
  13. inter_agent/core/send.py +265 -0
  14. inter_agent/core/server.py +765 -0
  15. inter_agent/core/shared.py +182 -0
  16. inter_agent/core/shutdown.py +92 -0
  17. inter_agent/core/status.py +291 -0
  18. inter_agent/core/tls.py +141 -0
  19. inter_agent/core/transport.py +37 -0
  20. inter_agent/py.typed +0 -0
  21. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/asyncapi.yaml +225 -0
  22. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/error-codes.md +28 -0
  23. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_challenge.json +6 -0
  24. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_response.json +4 -0
  25. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/broadcast.json +4 -0
  26. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/bye.json +3 -0
  27. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels.json +3 -0
  28. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels_ok.json +9 -0
  29. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/custom.unknown-pass-through.json +9 -0
  30. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/error.auth-failed.json +5 -0
  31. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent-with-label.json +14 -0
  32. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent.json +13 -0
  33. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick.json +4 -0
  34. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick_ok.json +5 -0
  35. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list.json +3 -0
  36. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list_ok.json +10 -0
  37. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.custom.json +13 -0
  38. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.text.json +9 -0
  39. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/ping.json +3 -0
  40. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/pong.json +3 -0
  41. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/publish.json +5 -0
  42. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/send.direct.json +5 -0
  43. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown.json +3 -0
  44. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown_ok.json +3 -0
  45. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe.json +4 -0
  46. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe_ok.json +4 -0
  47. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe.json +4 -0
  48. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe_ok.json +4 -0
  49. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/welcome.json +10 -0
  50. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_challenge.json +13 -0
  51. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_response.json +11 -0
  52. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/broadcast.json +18 -0
  53. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/bye.json +10 -0
  54. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels.json +10 -0
  55. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels_ok.json +26 -0
  56. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/custom.json +22 -0
  57. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/error.json +37 -0
  58. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/hello.json +59 -0
  59. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick.json +13 -0
  60. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick_ok.json +12 -0
  61. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list.json +10 -0
  62. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list_ok.json +24 -0
  63. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/msg.json +29 -0
  64. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/ping.json +10 -0
  65. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/pong.json +10 -0
  66. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/publish.json +23 -0
  67. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/send.json +22 -0
  68. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown.json +10 -0
  69. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown_ok.json +10 -0
  70. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe.json +15 -0
  71. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe_ok.json +11 -0
  72. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe.json +15 -0
  73. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe_ok.json +11 -0
  74. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/welcome.json +36 -0
  75. inter_agent_core-0.2.0.dist-info/METADATA +85 -0
  76. inter_agent_core-0.2.0.dist-info/RECORD +80 -0
  77. inter_agent_core-0.2.0.dist-info/WHEEL +5 -0
  78. inter_agent_core-0.2.0.dist-info/entry_points.txt +10 -0
  79. inter_agent_core-0.2.0.dist-info/licenses/LICENSE.md +21 -0
  80. inter_agent_core-0.2.0.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1 @@
1
+ """Core package for inter-agent."""
@@ -0,0 +1,350 @@
1
+ """Private local Unix-domain socket control bridge.
2
+
3
+ Short-lived adapter commands (``subscribe``, ``unsubscribe``) talk to the
4
+ matching live agent listener through a local Unix-domain socket instead of
5
+ opening a new agent or control identity on the bus. One newline-delimited
6
+ JSON request and response are exchanged per connection.
7
+
8
+ The bridge is strictly local and private: it never carries the shared server
9
+ secret and never accepts anything but ``subscribe`` and ``unsubscribe``
10
+ requests. Each endpoint is derived from the adapter, normalized endpoint,
11
+ and routing name so distinct listeners never collide on the socket path.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import hashlib
18
+ import json
19
+ import os
20
+ from collections.abc import Awaitable, Callable
21
+ from pathlib import Path
22
+
23
+ CONTROL_DIR_NAME = "control"
24
+ CONTROL_TIMEOUT_S = 2.0
25
+ CONTROL_MAX_REQUEST_BYTES = 64 * 1024
26
+ SUPPORTED_OPS = frozenset({"subscribe", "unsubscribe"})
27
+
28
+ #: StreamReader buffer limit set one byte above the request cap so a request at
29
+ #: or below the cap is read in full and checked explicitly, while a genuinely
30
+ #: oversized line raises ``LimitOverrunError`` before the length check.
31
+ _READ_LIMIT = CONTROL_MAX_REQUEST_BYTES + 1
32
+
33
+ #: Exactly the request keys the bridge accepts. Any other key is rejected so a
34
+ #: compromised or buggy command cannot exfiltrate state through the bridge.
35
+ _REQUEST_KEYS = frozenset({"op", "channel"})
36
+
37
+ #: Amount of the SHA-256 digest carried in the socket filename so the path
38
+ #: stays bounded regardless of name length.
39
+ _SOCKET_HASH_LEN = 16
40
+
41
+
42
+ class ControlError(Exception):
43
+ """Local control-bridge failure mapped to a clean stderr diagnostic."""
44
+
45
+
46
+ RequestHandler = Callable[[str, str], Awaitable[dict[str, object]]]
47
+
48
+
49
+ def _normalize_host(host: str) -> str:
50
+ return host.strip().lower()
51
+
52
+
53
+ def control_dir(base_data_dir: Path) -> Path:
54
+ """Return the ``control/`` child of an adapter data directory (mode 0700).
55
+
56
+ Permission failures are surfaced rather than swallowed: a control
57
+ directory that cannot be locked down to ``0700`` must not be used.
58
+ """
59
+ path = base_data_dir / CONTROL_DIR_NAME
60
+ path.mkdir(parents=True, exist_ok=True)
61
+ os.chmod(path, 0o700)
62
+ return path
63
+
64
+
65
+ def control_socket_path(
66
+ adapter: str,
67
+ host: str,
68
+ port: int,
69
+ name: str,
70
+ base_data_dir: Path,
71
+ ) -> Path:
72
+ """Derive a bounded, collision-free socket path for one listener identity.
73
+
74
+ Surfaces directory setup/permission failures to the caller instead of
75
+ returning a path whose parent directory could not be secured.
76
+ """
77
+ digest = hashlib.sha256()
78
+ digest.update(adapter.encode("utf-8"))
79
+ digest.update(b"\x00")
80
+ digest.update(_normalize_host(host).encode("utf-8"))
81
+ digest.update(b"\x00")
82
+ digest.update(str(port).encode("utf-8"))
83
+ digest.update(b"\x00")
84
+ digest.update(name.encode("utf-8"))
85
+ suffix = digest.hexdigest()[:_SOCKET_HASH_LEN]
86
+ return control_dir(base_data_dir) / f"control-{adapter}-{suffix}.sock"
87
+
88
+
89
+ def _local_error(code: str, message: str) -> dict[str, object]:
90
+ return {"op": "error", "code": code, "message": message}
91
+
92
+
93
+ async def _write_line(writer: asyncio.StreamWriter, payload: dict[str, object]) -> None:
94
+ data = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
95
+ writer.write(data)
96
+ await asyncio.wait_for(writer.drain(), timeout=CONTROL_TIMEOUT_S)
97
+
98
+
99
+ async def probe_alive(path: Path) -> bool:
100
+ """Return True when a listener is accepting connections at ``path``."""
101
+ try:
102
+ _reader, writer = await asyncio.wait_for(
103
+ asyncio.open_unix_connection(str(path), limit=_READ_LIMIT),
104
+ timeout=CONTROL_TIMEOUT_S,
105
+ )
106
+ except (FileNotFoundError, ConnectionRefusedError, TimeoutError, OSError):
107
+ return False
108
+ try:
109
+ writer.close()
110
+ try:
111
+ await writer.wait_closed()
112
+ except (OSError, asyncio.CancelledError):
113
+ pass
114
+ except OSError:
115
+ pass
116
+ return True
117
+
118
+
119
+ class ControlServer:
120
+ """Listener-side Unix-domain control socket bound to one live session.
121
+
122
+ A single server owns its socket; on startup it removes a pre-existing
123
+ socket file only after a failed liveness probe, and on shutdown it unlinks
124
+ the endpoint only if it still owns it. Setup/permission failures fail
125
+ closed: the server is not exposed and control is reported as unavailable
126
+ rather than left in a permissive state or allowed to break the listener.
127
+ """
128
+
129
+ def __init__(self, path: Path, handle: RequestHandler) -> None:
130
+ self._path = path
131
+ self._handle = handle
132
+ self._server: asyncio.Server | None = None
133
+ self._inode: int | None = None
134
+ self._dev: int | None = None
135
+
136
+ async def start(self) -> bool:
137
+ """Bind the control socket securely.
138
+
139
+ Returns False (control unavailable) if a live endpoint already owns the
140
+ socket, or if binding or securing the socket fails. On a permission
141
+ failure after binding, the server is closed and the just-created endpoint
142
+ is removed so no permissive socket is ever exposed.
143
+ """
144
+ if self._path.exists():
145
+ if await probe_alive(self._path):
146
+ return False
147
+ try:
148
+ self._path.unlink()
149
+ except OSError:
150
+ return False
151
+ try:
152
+ self._server = await asyncio.start_unix_server(
153
+ self._handle_connection, path=str(self._path), limit=_READ_LIMIT
154
+ )
155
+ except OSError:
156
+ self._server = None
157
+ return False
158
+ # Capture the just-bound endpoint's identity immediately so setup-time
159
+ # cleanup only unlinks the endpoint this server created, even though full
160
+ # ownership is only recorded after chmod/stat succeed.
161
+ bound_inode: int | None
162
+ bound_dev: int | None
163
+ try:
164
+ bound_stat = self._path.stat()
165
+ bound_inode = bound_stat.st_ino
166
+ bound_dev = bound_stat.st_dev
167
+ except OSError:
168
+ await self._close_server()
169
+ return False
170
+ try:
171
+ os.chmod(self._path, 0o600)
172
+ except OSError:
173
+ await self._close_server()
174
+ await self._unlink_owned(bound_inode, bound_dev)
175
+ return False
176
+ try:
177
+ stat = self._path.stat()
178
+ except OSError:
179
+ await self._close_server()
180
+ await self._unlink_owned(bound_inode, bound_dev)
181
+ return False
182
+ # The endpoint still exists and is still the one we bound. Confirm
183
+ # it hasn't been replaced out from under us before accepting ownership.
184
+ if stat.st_ino != bound_inode or stat.st_dev != bound_dev:
185
+ await self._close_server()
186
+ return False
187
+ self._inode = stat.st_ino
188
+ self._dev = stat.st_dev
189
+ return True
190
+
191
+ async def _close_server(self) -> None:
192
+ server = self._server
193
+ self._server = None
194
+ if server is not None:
195
+ server.close()
196
+ try:
197
+ await server.wait_closed()
198
+ except (OSError, asyncio.CancelledError):
199
+ pass
200
+
201
+ async def _handle_connection(
202
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
203
+ ) -> None:
204
+ try:
205
+ try:
206
+ raw = await asyncio.wait_for(reader.readline(), timeout=CONTROL_TIMEOUT_S)
207
+ except TimeoutError:
208
+ return
209
+ except (asyncio.LimitOverrunError, ValueError):
210
+ # A genuinely oversized line raises before the length check.
211
+ await _write_line(
212
+ writer, _local_error("REQUEST_TOO_LARGE", "control request too large")
213
+ )
214
+ return
215
+ if not raw:
216
+ return
217
+ if len(raw) > CONTROL_MAX_REQUEST_BYTES:
218
+ await _write_line(
219
+ writer, _local_error("REQUEST_TOO_LARGE", "control request too large")
220
+ )
221
+ return
222
+ try:
223
+ payload: object = json.loads(raw.decode("utf-8"))
224
+ except (json.JSONDecodeError, UnicodeDecodeError):
225
+ await _write_line(writer, _local_error("BAD_REQUEST", "malformed control request"))
226
+ return
227
+ if not isinstance(payload, dict):
228
+ await _write_line(
229
+ writer, _local_error("BAD_REQUEST", "control request must be an object")
230
+ )
231
+ return
232
+ if set(payload.keys()) != _REQUEST_KEYS:
233
+ await _write_line(
234
+ writer,
235
+ _local_error("BAD_REQUEST", "control request must contain only op and channel"),
236
+ )
237
+ return
238
+ op = payload.get("op")
239
+ channel = payload.get("channel")
240
+ if not isinstance(op, str) or op not in SUPPORTED_OPS:
241
+ await _write_line(writer, _local_error("BAD_OP", "unsupported control op"))
242
+ return
243
+ if not isinstance(channel, str) or not channel:
244
+ await _write_line(writer, _local_error("BAD_CHANNEL", "channel required"))
245
+ return
246
+ try:
247
+ response = await asyncio.wait_for(
248
+ self._handle(op, channel), timeout=CONTROL_TIMEOUT_S
249
+ )
250
+ except TimeoutError:
251
+ response = _local_error("TIMEOUT", "listener did not respond in time")
252
+ except Exception as exc: # listener-side failure, never propagate as traceback
253
+ response = _local_error("LISTENER_UNAVAILABLE", str(exc))
254
+ await _write_line(writer, response)
255
+ finally:
256
+ try:
257
+ writer.close()
258
+ await writer.wait_closed()
259
+ except (OSError, asyncio.CancelledError, BrokenPipeError):
260
+ pass
261
+
262
+ async def stop(self) -> None:
263
+ await self._close_server()
264
+ await self._unlink_owned(self._inode, self._dev)
265
+ self._inode = None
266
+ self._dev = None
267
+
268
+ async def _unlink_owned(self, inode: int | None, dev: int | None) -> None:
269
+ """Unlink the endpoint only if its identity still matches.
270
+
271
+ Used by both normal shutdown and setup-time failure cleanup so a
272
+ server never removes an endpoint it no longer owns (replacement race).
273
+ """
274
+ if inode is None or dev is None:
275
+ return
276
+ try:
277
+ stat = self._path.stat()
278
+ except OSError:
279
+ return
280
+ if stat.st_ino == inode and stat.st_dev == dev:
281
+ try:
282
+ self._path.unlink()
283
+ except OSError:
284
+ pass
285
+
286
+
287
+ async def request(
288
+ adapter: str,
289
+ host: str,
290
+ port: int,
291
+ name: str,
292
+ base_data_dir: Path,
293
+ op: str,
294
+ channel: str,
295
+ ) -> dict[str, object]:
296
+ """Send one control request to the listener owning ``name``'s socket.
297
+
298
+ Path/setup, connect, read, and decode failures are converted to clean
299
+ ``ControlError`` diagnostics; oversized or malformed responses raise rather
300
+ than returning partial data.
301
+ """
302
+ try:
303
+ path = control_socket_path(adapter, host, port, name, base_data_dir)
304
+ except OSError as exc:
305
+ raise ControlError(f"control socket unavailable: {exc}") from exc
306
+ try:
307
+ reader, writer = await asyncio.wait_for(
308
+ asyncio.open_unix_connection(str(path), limit=_READ_LIMIT),
309
+ timeout=CONTROL_TIMEOUT_S,
310
+ )
311
+ except FileNotFoundError as exc:
312
+ raise ControlError("not connected; start the listener first") from exc
313
+ except ConnectionRefusedError as exc:
314
+ raise ControlError("listener not reachable; reconnecting or not running") from exc
315
+ except TimeoutError as exc:
316
+ raise ControlError("timed out connecting to listener") from exc
317
+ except OSError as exc:
318
+ raise ControlError(f"listener control connection failed: {exc}") from exc
319
+
320
+ try:
321
+ request_payload = json.dumps({"op": op, "channel": channel}) + "\n"
322
+ writer.write(request_payload.encode("utf-8"))
323
+ try:
324
+ await asyncio.wait_for(writer.drain(), timeout=CONTROL_TIMEOUT_S)
325
+ except TimeoutError as exc:
326
+ raise ControlError("timed out sending control request") from exc
327
+ try:
328
+ raw = await asyncio.wait_for(reader.readline(), timeout=CONTROL_TIMEOUT_S)
329
+ except TimeoutError as exc:
330
+ raise ControlError("timed out waiting for listener response") from exc
331
+ except (asyncio.LimitOverrunError, ValueError) as exc:
332
+ raise ControlError("oversized response from listener") from exc
333
+ finally:
334
+ try:
335
+ writer.close()
336
+ await writer.wait_closed()
337
+ except (OSError, asyncio.CancelledError, BrokenPipeError):
338
+ pass
339
+
340
+ if not raw:
341
+ raise ControlError("no response from listener")
342
+ if len(raw) > CONTROL_MAX_REQUEST_BYTES:
343
+ raise ControlError("oversized response from listener")
344
+ try:
345
+ payload: object = json.loads(raw.decode("utf-8"))
346
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
347
+ raise ControlError("malformed response from listener") from exc
348
+ if not isinstance(payload, dict):
349
+ raise ControlError("response from listener must be an object")
350
+ return {str(key): value for key, value in payload.items()}
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import secrets
7
+ from dataclasses import dataclass
8
+ from typing import Protocol
9
+
10
+ AUTH_METHOD = "hmac-sha256"
11
+ SERVER_PROOF_DOMAIN = "inter-agent/server-proof/v1"
12
+ CLIENT_PROOF_DOMAIN = "inter-agent/client-proof/v1"
13
+
14
+
15
+ class AuthError(RuntimeError):
16
+ """Raised when challenge-response authentication fails."""
17
+
18
+
19
+ class AuthProtocolError(RuntimeError):
20
+ """Raised when a peer does not speak the auth handshake protocol."""
21
+
22
+
23
+ class HandshakeWebSocket(Protocol):
24
+ async def send(self, message: str) -> None: ...
25
+
26
+ async def recv(self) -> str | bytes: ...
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AuthChallenge:
31
+ server_nonce: str
32
+ server_proof: str
33
+
34
+
35
+ def generate_nonce() -> str:
36
+ """Return a high-entropy URL-safe nonce for auth handshakes."""
37
+ return secrets.token_urlsafe(32)
38
+
39
+
40
+ def canonical_hello_transcript(hello: dict[str, object]) -> str:
41
+ """Return the canonical hello fields bound into auth proofs."""
42
+ payload = {
43
+ "role": hello.get("role"),
44
+ "session_id": hello.get("session_id"),
45
+ "name": hello.get("name"),
46
+ "label": hello.get("label"),
47
+ "capabilities": hello.get("capabilities"),
48
+ }
49
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
50
+
51
+
52
+ def _proof_message(
53
+ domain: str,
54
+ *,
55
+ client_nonce: str,
56
+ server_nonce: str,
57
+ hello: dict[str, object],
58
+ ) -> bytes:
59
+ transcript = canonical_hello_transcript(hello)
60
+ return f"{domain}\n{client_nonce}\n{server_nonce}\n{transcript}".encode()
61
+
62
+
63
+ def generate_proof(
64
+ secret: str,
65
+ domain: str,
66
+ *,
67
+ client_nonce: str,
68
+ server_nonce: str,
69
+ hello: dict[str, object],
70
+ ) -> str:
71
+ """Generate a hex HMAC-SHA-256 auth proof."""
72
+ return hmac.new(
73
+ secret.encode("utf-8"),
74
+ _proof_message(domain, client_nonce=client_nonce, server_nonce=server_nonce, hello=hello),
75
+ hashlib.sha256,
76
+ ).hexdigest()
77
+
78
+
79
+ def server_proof(
80
+ secret: str,
81
+ *,
82
+ client_nonce: str,
83
+ server_nonce: str,
84
+ hello: dict[str, object],
85
+ ) -> str:
86
+ return generate_proof(
87
+ secret,
88
+ SERVER_PROOF_DOMAIN,
89
+ client_nonce=client_nonce,
90
+ server_nonce=server_nonce,
91
+ hello=hello,
92
+ )
93
+
94
+
95
+ def client_proof(
96
+ secret: str,
97
+ *,
98
+ client_nonce: str,
99
+ server_nonce: str,
100
+ hello: dict[str, object],
101
+ ) -> str:
102
+ return generate_proof(
103
+ secret,
104
+ CLIENT_PROOF_DOMAIN,
105
+ client_nonce=client_nonce,
106
+ server_nonce=server_nonce,
107
+ hello=hello,
108
+ )
109
+
110
+
111
+ def verify_server_proof(
112
+ proof: str,
113
+ secret: str,
114
+ *,
115
+ client_nonce: str,
116
+ server_nonce: str,
117
+ hello: dict[str, object],
118
+ ) -> bool:
119
+ expected = server_proof(
120
+ secret,
121
+ client_nonce=client_nonce,
122
+ server_nonce=server_nonce,
123
+ hello=hello,
124
+ )
125
+ return hmac.compare_digest(proof, expected)
126
+
127
+
128
+ def verify_client_proof(
129
+ proof: str,
130
+ secret: str,
131
+ *,
132
+ client_nonce: str,
133
+ server_nonce: str,
134
+ hello: dict[str, object],
135
+ ) -> bool:
136
+ expected = client_proof(
137
+ secret,
138
+ client_nonce=client_nonce,
139
+ server_nonce=server_nonce,
140
+ hello=hello,
141
+ )
142
+ return hmac.compare_digest(proof, expected)
143
+
144
+
145
+ def auth_object(client_nonce: str) -> dict[str, object]:
146
+ return {"method": AUTH_METHOD, "client_nonce": client_nonce}
147
+
148
+
149
+ def build_hello(
150
+ *,
151
+ role: str,
152
+ session_id: str,
153
+ name: str,
154
+ label: str | None = None,
155
+ capabilities: dict[str, object] | None = None,
156
+ client_nonce: str | None = None,
157
+ ) -> dict[str, object]:
158
+ nonce = client_nonce or generate_nonce()
159
+ payload: dict[str, object] = {
160
+ "op": "hello",
161
+ "auth": auth_object(nonce),
162
+ "role": role,
163
+ "session_id": session_id,
164
+ "name": name,
165
+ "capabilities": {} if capabilities is None else capabilities,
166
+ }
167
+ if label is not None:
168
+ payload["label"] = label
169
+ return payload
170
+
171
+
172
+ def client_nonce_from_hello(hello: dict[str, object]) -> str | None:
173
+ auth = hello.get("auth")
174
+ if not isinstance(auth, dict):
175
+ return None
176
+ if auth.get("method") != AUTH_METHOD:
177
+ return None
178
+ nonce = auth.get("client_nonce")
179
+ if not isinstance(nonce, str) or not nonce:
180
+ return None
181
+ return nonce
182
+
183
+
184
+ def build_auth_challenge(
185
+ secret: str,
186
+ *,
187
+ client_nonce: str,
188
+ hello: dict[str, object],
189
+ server_nonce: str | None = None,
190
+ ) -> dict[str, object]:
191
+ nonce = server_nonce or generate_nonce()
192
+ return {
193
+ "op": "auth_challenge",
194
+ "method": AUTH_METHOD,
195
+ "server_nonce": nonce,
196
+ "server_proof": server_proof(
197
+ secret,
198
+ client_nonce=client_nonce,
199
+ server_nonce=nonce,
200
+ hello=hello,
201
+ ),
202
+ }
203
+
204
+
205
+ def parse_auth_challenge(payload: dict[str, object]) -> AuthChallenge:
206
+ if payload.get("op") != "auth_challenge" or payload.get("method") != AUTH_METHOD:
207
+ raise AuthProtocolError("expected auth_challenge")
208
+ server_nonce = payload.get("server_nonce")
209
+ proof = payload.get("server_proof")
210
+ if not isinstance(server_nonce, str) or not server_nonce:
211
+ raise AuthProtocolError("auth_challenge missing server_nonce")
212
+ if not isinstance(proof, str) or not proof:
213
+ raise AuthProtocolError("auth_challenge missing server_proof")
214
+ return AuthChallenge(server_nonce=server_nonce, server_proof=proof)
215
+
216
+
217
+ def build_auth_response(
218
+ secret: str,
219
+ *,
220
+ client_nonce: str,
221
+ server_nonce: str,
222
+ hello: dict[str, object],
223
+ ) -> dict[str, object]:
224
+ return {
225
+ "op": "auth_response",
226
+ "client_proof": client_proof(
227
+ secret,
228
+ client_nonce=client_nonce,
229
+ server_nonce=server_nonce,
230
+ hello=hello,
231
+ ),
232
+ }
233
+
234
+
235
+ def _text_frame(frame: str | bytes) -> str:
236
+ if isinstance(frame, bytes):
237
+ return frame.decode("utf-8")
238
+ return frame
239
+
240
+
241
+ def _json_object(raw: str) -> dict[str, object]:
242
+ payload: object = json.loads(raw)
243
+ if not isinstance(payload, dict):
244
+ raise AuthProtocolError("auth frame must be a JSON object")
245
+ return {str(key): value for key, value in payload.items()}
246
+
247
+
248
+ async def client_handshake(ws: HandshakeWebSocket, secret: str, hello: dict[str, object]) -> str:
249
+ """Run client side challenge-response and return the final server frame."""
250
+ client_nonce = client_nonce_from_hello(hello)
251
+ if client_nonce is None:
252
+ raise AuthProtocolError("hello missing auth client_nonce")
253
+
254
+ await ws.send(json.dumps(hello))
255
+ challenge_raw = _text_frame(await ws.recv())
256
+ challenge_payload = _json_object(challenge_raw)
257
+ if challenge_payload.get("op") == "error":
258
+ return challenge_raw
259
+ challenge = parse_auth_challenge(challenge_payload)
260
+ if not verify_server_proof(
261
+ challenge.server_proof,
262
+ secret,
263
+ client_nonce=client_nonce,
264
+ server_nonce=challenge.server_nonce,
265
+ hello=hello,
266
+ ):
267
+ raise AuthError("server authentication failed")
268
+
269
+ await ws.send(
270
+ json.dumps(
271
+ build_auth_response(
272
+ secret,
273
+ client_nonce=client_nonce,
274
+ server_nonce=challenge.server_nonce,
275
+ hello=hello,
276
+ )
277
+ )
278
+ )
279
+ return _text_frame(await ws.recv())