stealth-message-cli 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.
@@ -0,0 +1,519 @@
1
+ """WebSocket client — stealth-message protocol joiner (protocol.md §1–§4).
2
+
3
+ Connects to a host running :class:`~stealth_cli.network.server.StealthServer`,
4
+ performs the handshake, and provides a simple API for sending encrypted
5
+ messages, pinging, and disconnecting cleanly.
6
+
7
+ Usage example::
8
+
9
+ client = StealthClient("Bob", armored_privkey, passphrase)
10
+
11
+ async def on_msg(plaintext: str) -> None:
12
+ print(f"[server] {plaintext}")
13
+
14
+ client.on_message = on_msg
15
+ await client.connect("ws://192.168.1.10:8765")
16
+
17
+ print("Server fingerprint:", client.peer_fingerprint)
18
+ await client.send_message("Hello!")
19
+ rtt_ms = await client.ping()
20
+ await client.disconnect()
21
+
22
+ All callbacks must be ``async def`` functions.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import base64
29
+ import json
30
+ import logging
31
+ import time
32
+ import uuid
33
+ from typing import Any, Awaitable, Callable
34
+
35
+ import websockets
36
+ import websockets.exceptions
37
+ from websockets.asyncio.client import ClientConnection, connect
38
+
39
+ from stealth_cli.crypto.keys import get_fingerprint, load_private_key
40
+ from stealth_cli.crypto.messages import decrypt, encrypt
41
+ from stealth_cli.exceptions import ProtocolError, SignatureError
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ PROTOCOL_VERSION = "1"
46
+ HANDSHAKE_TIMEOUT = 10.0 # seconds — protocol §1.1
47
+ PONG_TIMEOUT = 10.0 # seconds — protocol §3.2
48
+ JOIN_REQUEST_TIMEOUT = 65.0 # slightly more than server's 60s timeout
49
+
50
+
51
+ class StealthClient:
52
+ """WebSocket client implementing the stealth-message protocol.
53
+
54
+ Attributes:
55
+ on_message: Called when a decrypted message arrives from the server.
56
+ Signature: ``async def cb(plaintext: str) -> None``
57
+ on_disconnected: Called when the connection is closed by either side.
58
+ Signature: ``async def cb() -> None``
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ alias: str,
64
+ armored_privkey: str,
65
+ passphrase: str,
66
+ ) -> None:
67
+ self._alias: str = alias[:64] # §1.1: max 64 UTF-8 chars
68
+ self._privkey = load_private_key(armored_privkey, passphrase)
69
+ self._passphrase: str = passphrase
70
+ self._armored_pubkey: str = str(self._privkey.pubkey)
71
+
72
+ self._ws: ClientConnection | None = None
73
+ self._recv_task: asyncio.Task[None] | None = None
74
+ self._pong_event: asyncio.Event | None = None
75
+
76
+ # Room — set by connect().
77
+ self._room_id: str = "default"
78
+ # Group room approval state.
79
+ self._pending_approval_event: asyncio.Event | None = None
80
+ self._approved: bool = False
81
+
82
+ # Peer state — populated after a successful handshake.
83
+ self._peer_alias: str | None = None
84
+ self._peer_armored_pubkey: str | None = None
85
+ self._peer_fingerprint: str | None = None
86
+
87
+ # Public callbacks.
88
+ # Signature: async def cb(plaintext: str, sender: str | None) -> None
89
+ # sender is None for direct messages, or the originating peer alias for
90
+ # group-room forwarded messages.
91
+ self.on_message: Callable[[str, str | None], Awaitable[None]] | None = None
92
+ self.on_disconnected: Callable[[], Awaitable[None]] | None = None
93
+ # Called when the server puts this client in pending state (group room).
94
+ self.on_pending: Callable[[], Awaitable[None]] | None = None
95
+ # Called when the host approves entry into a group room.
96
+ self.on_approved: Callable[[], Awaitable[None]] | None = None
97
+ # Called when the host asks this client to move to a different room.
98
+ # Signature: async def cb(room_id: str) -> None
99
+ self.on_move: Callable[[str], Awaitable[None]] | None = None
100
+ # Called when the server sends the list of discoverable group rooms.
101
+ # Signature: async def cb(group_rooms: list[str]) -> None
102
+ self.on_roomlist: Callable[[list[str]], Awaitable[None]] | None = None
103
+ # Called when the server sends the list of peers in the current group room.
104
+ # Signature: async def cb(peers: list[dict[str, str]]) -> None
105
+ # Each dict has "alias" and "fingerprint" keys.
106
+ self.on_peerlist: Callable[[list[dict[str, str]]], Awaitable[None]] | None = None
107
+ # Called when the host force-disconnects this client (protocol.md §5).
108
+ # Signature: async def cb(reason: str) -> None
109
+ self.on_kicked: Callable[[str], Awaitable[None]] | None = None
110
+
111
+ # ------------------------------------------------------------------ #
112
+ # Peer identity (available after connect) #
113
+ # ------------------------------------------------------------------ #
114
+
115
+ @property
116
+ def peer_alias(self) -> str:
117
+ """Alias of the connected server peer."""
118
+ if self._peer_alias is None:
119
+ raise RuntimeError("Not connected — call connect() first")
120
+ return self._peer_alias
121
+
122
+ @property
123
+ def peer_fingerprint(self) -> str:
124
+ """PGP fingerprint of the server's public key (groups of 4 chars)."""
125
+ if self._peer_fingerprint is None:
126
+ raise RuntimeError("Not connected — call connect() first")
127
+ return self._peer_fingerprint
128
+
129
+ @property
130
+ def peer_armored_pubkey(self) -> str:
131
+ """ASCII-armored PGP public key of the connected server."""
132
+ if self._peer_armored_pubkey is None:
133
+ raise RuntimeError("Not connected — call connect() first")
134
+ return self._peer_armored_pubkey
135
+
136
+ @property
137
+ def room_id(self) -> str:
138
+ """Room the client is connected to."""
139
+ return self._room_id
140
+
141
+ # ------------------------------------------------------------------ #
142
+ # Public API #
143
+ # ------------------------------------------------------------------ #
144
+
145
+ async def connect(self, uri: str, room_id: str = "default") -> None:
146
+ """Connect to a StealthServer, perform the handshake, and start receiving.
147
+
148
+ Args:
149
+ uri: WebSocket URI, e.g. ``"ws://localhost:8765"``.
150
+ room_id: Room to join on the server (protocol §1.1). Defaults to
151
+ ``"default"``.
152
+
153
+ Raises:
154
+ TimeoutError: If the handshake is not completed within 10 s.
155
+ :exc:`~stealth_cli.exceptions.ProtocolError`: On protocol violations
156
+ including room-full (4006) and room-not-found (4007).
157
+ :exc:`websockets.exceptions.WebSocketException`: On connection failure.
158
+ """
159
+ self._room_id = room_id[:64] or "default"
160
+ # Disable websockets' built-in ping so our own protocol ping/pong (§3.2)
161
+ # is the only keep-alive in play.
162
+ self._ws = await connect(uri, ping_interval=None)
163
+ try:
164
+ await asyncio.wait_for(self._handshake(), timeout=HANDSHAKE_TIMEOUT)
165
+ except (asyncio.TimeoutError, ProtocolError, Exception):
166
+ await self._ws.close()
167
+ self._ws = None
168
+ raise
169
+
170
+ # If the server put us in pending state, wait for approval before
171
+ # returning — this keeps connect() blocking until the host decides.
172
+ if self._pending_approval_event is not None:
173
+ # Start a minimal receive task just to process pending/approved/error.
174
+ approval_recv = asyncio.create_task(
175
+ self._approval_loop(), name="stealth-client-approval"
176
+ )
177
+ try:
178
+ await asyncio.wait_for(
179
+ self._pending_approval_event.wait(),
180
+ timeout=JOIN_REQUEST_TIMEOUT,
181
+ )
182
+ except asyncio.TimeoutError:
183
+ approval_recv.cancel()
184
+ await self._ws.close()
185
+ self._ws = None
186
+ self._pending_approval_event = None
187
+ raise ProtocolError("approval timed out waiting for host", 4008)
188
+ finally:
189
+ approval_recv.cancel()
190
+ try:
191
+ await approval_recv
192
+ except (asyncio.CancelledError, Exception):
193
+ pass
194
+
195
+ if not self._approved:
196
+ await self._ws.close()
197
+ self._ws = None
198
+ self._pending_approval_event = None
199
+ raise ProtocolError("join request denied by host", 4008)
200
+
201
+ self._pending_approval_event = None
202
+
203
+ # Start background receive loop.
204
+ self._recv_task = asyncio.create_task(
205
+ self._receive_loop(), name="stealth-client-recv"
206
+ )
207
+
208
+ async def disconnect(self) -> None:
209
+ """Send a ``bye`` frame, close the connection, and stop the receive loop."""
210
+ if self._ws is not None:
211
+ try:
212
+ await self._ws.send(json.dumps({"type": "bye"}))
213
+ await self._ws.close()
214
+ except websockets.exceptions.ConnectionClosed:
215
+ pass
216
+
217
+ if self._recv_task is not None:
218
+ self._recv_task.cancel()
219
+ try:
220
+ await self._recv_task
221
+ except (asyncio.CancelledError, Exception):
222
+ pass
223
+ self._recv_task = None
224
+
225
+ async def send_message(self, plaintext: str) -> None:
226
+ """Encrypt ``plaintext`` for the server and send a §2.1 message.
227
+
228
+ Args:
229
+ plaintext: UTF-8 text to send.
230
+
231
+ Raises:
232
+ RuntimeError: If not connected.
233
+ """
234
+ if self._ws is None or self._peer_armored_pubkey is None:
235
+ raise RuntimeError("Not connected")
236
+
237
+ with self._privkey.unlock(self._passphrase):
238
+ payload = encrypt(plaintext, self._peer_armored_pubkey, self._privkey)
239
+
240
+ await self._ws.send(
241
+ json.dumps(
242
+ {
243
+ "type": "message",
244
+ "id": str(uuid.uuid4()),
245
+ "payload": payload,
246
+ "timestamp": int(time.time() * 1000),
247
+ }
248
+ )
249
+ )
250
+
251
+ async def ping(self) -> float:
252
+ """Send a protocol ``ping`` and wait for the server's ``pong``.
253
+
254
+ Returns:
255
+ Round-trip time in milliseconds.
256
+
257
+ Raises:
258
+ RuntimeError: If not connected.
259
+ TimeoutError: If no ``pong`` is received within 10 s (§3.2).
260
+ """
261
+ if self._ws is None:
262
+ raise RuntimeError("Not connected")
263
+
264
+ self._pong_event = asyncio.Event()
265
+ start = time.monotonic()
266
+ await self._ws.send(json.dumps({"type": "ping"}))
267
+
268
+ try:
269
+ await asyncio.wait_for(self._pong_event.wait(), timeout=PONG_TIMEOUT)
270
+ except asyncio.TimeoutError as exc:
271
+ raise TimeoutError(
272
+ "No pong received within 10 seconds (§3.2)"
273
+ ) from exc
274
+
275
+ return (time.monotonic() - start) * 1000.0
276
+
277
+ # ------------------------------------------------------------------ #
278
+ # Handshake — §1.1 #
279
+ # ------------------------------------------------------------------ #
280
+
281
+ async def _handshake(self) -> None:
282
+ """Client-side handshake: send hello → receive server hello."""
283
+ assert self._ws is not None
284
+
285
+ # Client sends first (§1.1), including the target room.
286
+ await self._ws.send(
287
+ json.dumps(
288
+ {
289
+ "type": "hello",
290
+ "version": PROTOCOL_VERSION,
291
+ "room": self._room_id,
292
+ "alias": self._alias,
293
+ "pubkey": base64.urlsafe_b64encode(
294
+ self._armored_pubkey.encode("utf-8")
295
+ ).decode("ascii"),
296
+ }
297
+ )
298
+ )
299
+
300
+ raw = await self._ws.recv()
301
+ try:
302
+ msg: dict[str, Any] = json.loads(raw)
303
+ except (json.JSONDecodeError, TypeError) as exc:
304
+ raise ProtocolError("invalid JSON in server hello", 4002) from exc
305
+
306
+ # If the server rejected us (e.g. room full), propagate the error.
307
+ if msg.get("type") == "error":
308
+ code = int(msg.get("code") or 4002)
309
+ reason = str(msg.get("reason") or "server rejected connection")
310
+ raise ProtocolError(reason, code)
311
+
312
+ if msg.get("type") != "hello":
313
+ raise ProtocolError(
314
+ f"expected hello, got {msg.get('type')!r}", 4002
315
+ )
316
+
317
+ if str(msg.get("version")) != PROTOCOL_VERSION:
318
+ raise ProtocolError(
319
+ f"unsupported protocol version {msg.get('version')!r}", 4001
320
+ )
321
+
322
+ for required in ("alias", "pubkey"):
323
+ if not msg.get(required):
324
+ raise ProtocolError(f"hello missing field: {required!r}", 4002)
325
+
326
+ try:
327
+ peer_armored = base64.urlsafe_b64decode(
328
+ msg["pubkey"].encode("ascii") + b"=="
329
+ ).decode("utf-8")
330
+ except Exception as exc:
331
+ raise ProtocolError("invalid pubkey encoding in server hello", 4002) from exc
332
+
333
+ self._peer_alias = str(msg["alias"])[:64]
334
+ self._peer_armored_pubkey = peer_armored
335
+ self._peer_fingerprint = get_fingerprint(peer_armored)
336
+
337
+ # For group rooms the server may send a second frame: "pending".
338
+ # Peek at the next frame without blocking the full handshake timeout.
339
+ try:
340
+ raw2 = await asyncio.wait_for(self._ws.recv(), timeout=0.5)
341
+ try:
342
+ msg2: dict[str, Any] = json.loads(raw2)
343
+ except (json.JSONDecodeError, TypeError):
344
+ return # ignore unparseable second frame
345
+ if msg2.get("type") == "pending":
346
+ self._pending_approval_event = asyncio.Event()
347
+ self._approved = False
348
+ except asyncio.TimeoutError:
349
+ pass # no second frame — normal 1:1 room
350
+
351
+ # ------------------------------------------------------------------ #
352
+ # Approval loop (group rooms only) #
353
+ # ------------------------------------------------------------------ #
354
+
355
+ async def _approval_loop(self) -> None:
356
+ """Read frames until approved/denied — used only during connect()."""
357
+ assert self._ws is not None
358
+ try:
359
+ async for raw in self._ws:
360
+ try:
361
+ msg: dict[str, Any] = json.loads(raw)
362
+ except (json.JSONDecodeError, TypeError):
363
+ continue
364
+ msg_type = msg.get("type")
365
+ if msg_type == "approved":
366
+ self._approved = True
367
+ if self._pending_approval_event:
368
+ self._pending_approval_event.set()
369
+ return
370
+ if msg_type == "error":
371
+ self._approved = False
372
+ if self._pending_approval_event:
373
+ self._pending_approval_event.set()
374
+ return
375
+ except (websockets.exceptions.ConnectionClosed, asyncio.CancelledError):
376
+ if self._pending_approval_event and not self._pending_approval_event.is_set():
377
+ self._pending_approval_event.set()
378
+
379
+ # Receive loop #
380
+ # ------------------------------------------------------------------ #
381
+
382
+ async def _receive_loop(self) -> None:
383
+ """Background task: read frames from the WebSocket and dispatch them."""
384
+ assert self._ws is not None
385
+ try:
386
+ async for raw in self._ws:
387
+ await self._dispatch(raw)
388
+ except websockets.exceptions.ConnectionClosed:
389
+ pass
390
+ except asyncio.CancelledError:
391
+ raise
392
+ except Exception as exc:
393
+ logger.debug("Receive loop error: %s", exc)
394
+ finally:
395
+ if self.on_disconnected:
396
+ await self.on_disconnected()
397
+
398
+ async def _dispatch(self, raw: str | bytes) -> None:
399
+ """Parse and route one incoming WebSocket frame."""
400
+ try:
401
+ msg: dict[str, Any] = json.loads(raw)
402
+ except (json.JSONDecodeError, TypeError):
403
+ await self._safe_send_error(4002, "invalid JSON")
404
+ return
405
+
406
+ msg_type = msg.get("type")
407
+
408
+ if msg_type == "message":
409
+ await self._handle_chat(msg)
410
+ elif msg_type == "pong":
411
+ if self._pong_event is not None and not self._pong_event.is_set():
412
+ self._pong_event.set()
413
+ elif msg_type == "ping":
414
+ assert self._ws is not None
415
+ await self._ws.send(json.dumps({"type": "pong"}))
416
+ elif msg_type == "bye":
417
+ assert self._ws is not None
418
+ await self._ws.close()
419
+ elif msg_type == "kick":
420
+ reason = str(msg.get("reason") or "disconnected by host")
421
+ logger.info("Kicked by host: %s", reason)
422
+ if self.on_kicked:
423
+ await self.on_kicked(reason)
424
+ assert self._ws is not None
425
+ await self._ws.close()
426
+ elif msg_type == "pending":
427
+ if self.on_pending:
428
+ await self.on_pending()
429
+ elif msg_type == "approved":
430
+ if self.on_approved:
431
+ await self.on_approved()
432
+ elif msg_type == "move":
433
+ target_room = str(msg.get("room") or "")
434
+ if target_room and self.on_move:
435
+ await self.on_move(target_room)
436
+ elif msg_type == "roomlist":
437
+ groups = msg.get("groups")
438
+ if isinstance(groups, list) and self.on_roomlist:
439
+ await self.on_roomlist([str(r) for r in groups])
440
+ elif msg_type == "peerlist":
441
+ peers = msg.get("peers")
442
+ if isinstance(peers, list) and self.on_peerlist:
443
+ await self.on_peerlist(
444
+ [p for p in peers if isinstance(p, dict)]
445
+ )
446
+ elif msg_type == "error":
447
+ logger.warning(
448
+ "Error from server: code=%s reason=%s",
449
+ msg.get("code"),
450
+ msg.get("reason"),
451
+ )
452
+ elif msg_type is None:
453
+ await self._safe_send_error(4002, "missing 'type' field")
454
+ else:
455
+ logger.debug("Ignoring unknown message type %r from server", msg_type)
456
+
457
+ async def _handle_chat(self, msg: dict[str, Any]) -> None:
458
+ """Decrypt and deliver a §2.1 chat message."""
459
+ for required in ("id", "payload", "timestamp"):
460
+ if required not in msg:
461
+ await self._safe_send_error(
462
+ 4002, f"message missing field: {required!r}"
463
+ )
464
+ return
465
+
466
+ try:
467
+ with self._privkey.unlock(self._passphrase):
468
+ plaintext = decrypt(
469
+ msg["payload"], self._privkey, self._peer_armored_pubkey # type: ignore[arg-type]
470
+ )
471
+ except SignatureError:
472
+ await self._safe_send_error(4003, "PGP signature invalid")
473
+ return
474
+ except Exception as exc:
475
+ logger.debug("Decryption error: %s", exc)
476
+ await self._safe_send_error(4004, "decryption failed")
477
+ return
478
+
479
+ if self.on_message:
480
+ sender: str | None = msg.get("sender") or None # type: ignore[assignment]
481
+ await self.on_message(plaintext, sender)
482
+
483
+ async def _safe_send_error(self, code: int, reason: str) -> None:
484
+ """Send an error frame, ignoring a closed connection."""
485
+ if self._ws is None:
486
+ return
487
+ try:
488
+ await self._ws.send(
489
+ json.dumps({"type": "error", "code": code, "reason": reason})
490
+ )
491
+ except websockets.exceptions.ConnectionClosed:
492
+ pass
493
+
494
+
495
+ async def query_rooms(uri: str, timeout: float = 5.0) -> list[dict[str, Any]]:
496
+ """Query available rooms from a StealthServer without joining.
497
+
498
+ Sends a ``listrooms`` request and returns the server's room list.
499
+ Each entry is a dict with keys:
500
+ - ``id`` (str): room name
501
+ - ``kind`` (str): ``"1:1"`` or ``"group"``
502
+ - ``peers`` (int): number of currently connected peers
503
+ - ``available`` (bool): only present for 1:1 rooms; True if joinable
504
+
505
+ Returns an empty list if the server is unreachable or does not support
506
+ the ``listrooms`` request.
507
+ """
508
+ try:
509
+ async with connect(uri, ping_interval=None) as ws:
510
+ await ws.send(json.dumps({"type": "listrooms"}))
511
+ raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
512
+ msg: dict[str, Any] = json.loads(raw)
513
+ if msg.get("type") == "roomsinfo":
514
+ rooms = msg.get("rooms")
515
+ if isinstance(rooms, list):
516
+ return [r for r in rooms if isinstance(r, dict)]
517
+ except Exception as exc:
518
+ logger.debug("query_rooms failed: %s", exc)
519
+ return []