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.
- stealth_cli/__init__.py +0 -0
- stealth_cli/__main__.py +711 -0
- stealth_cli/config.py +132 -0
- stealth_cli/crypto/__init__.py +0 -0
- stealth_cli/crypto/keys.py +120 -0
- stealth_cli/crypto/messages.py +130 -0
- stealth_cli/exceptions.py +29 -0
- stealth_cli/network/__init__.py +0 -0
- stealth_cli/network/client.py +519 -0
- stealth_cli/network/server.py +690 -0
- stealth_cli/ui/__init__.py +0 -0
- stealth_cli/ui/chat.py +1048 -0
- stealth_cli/ui/setup.py +212 -0
- stealth_message_cli-0.1.0.dist-info/METADATA +72 -0
- stealth_message_cli-0.1.0.dist-info/RECORD +18 -0
- stealth_message_cli-0.1.0.dist-info/WHEEL +5 -0
- stealth_message_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stealth_message_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""WebSocket server — stealth-message protocol host (protocol.md §1–§4).
|
|
2
|
+
|
|
3
|
+
Room model
|
|
4
|
+
----------
|
|
5
|
+
Rooms can be **1-on-1** (default, max 1 peer) or **group** (unlimited peers
|
|
6
|
+
with host-approval gate).
|
|
7
|
+
|
|
8
|
+
* 1-on-1 room: second peer gets error 4006 immediately.
|
|
9
|
+
* Group room: every peer gets a ``pending`` message and waits up to
|
|
10
|
+
JOIN_REQUEST_TIMEOUT seconds for the host to ``/allow`` or ``/deny`` them.
|
|
11
|
+
The ``on_join_request`` callback fires so the host UI can display the
|
|
12
|
+
prompt.
|
|
13
|
+
|
|
14
|
+
The host can also call ``move_peer(alias, target_room)`` which sends a
|
|
15
|
+
``move`` message to that peer so their client can switch rooms automatically.
|
|
16
|
+
If the target is occupied the room is automatically converted to a group room
|
|
17
|
+
and the incoming peer is pre-approved (no approval prompt).
|
|
18
|
+
|
|
19
|
+
Usage example::
|
|
20
|
+
|
|
21
|
+
server = StealthServer("Alice", armored_privkey, passphrase,
|
|
22
|
+
rooms=["pepe", "juan"])
|
|
23
|
+
|
|
24
|
+
async def on_join_request(alias, fingerprint, room_id):
|
|
25
|
+
print(f"{alias} wants to join {room_id}")
|
|
26
|
+
|
|
27
|
+
server.on_join_request = on_join_request
|
|
28
|
+
await server.start(host="0.0.0.0", port=8765)
|
|
29
|
+
|
|
30
|
+
await server.approve_join("Pepe") # or deny_join(...)
|
|
31
|
+
await server.move_peer("Juan", "pepe")
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import base64
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import time
|
|
41
|
+
import uuid
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from typing import Any, Awaitable, Callable
|
|
44
|
+
|
|
45
|
+
import websockets
|
|
46
|
+
import websockets.exceptions
|
|
47
|
+
from websockets.asyncio.server import ServerConnection, serve
|
|
48
|
+
|
|
49
|
+
from stealth_cli.crypto.keys import get_fingerprint, load_private_key
|
|
50
|
+
from stealth_cli.crypto.messages import decrypt, encrypt
|
|
51
|
+
from stealth_cli.exceptions import ProtocolError, SignatureError
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
PROTOCOL_VERSION = "1"
|
|
56
|
+
HANDSHAKE_TIMEOUT = 10.0 # seconds — protocol §1.1
|
|
57
|
+
JOIN_REQUEST_TIMEOUT = 60.0 # seconds — host must respond within this time
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------------------- #
|
|
61
|
+
# Per-connection state #
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class PeerSession:
|
|
67
|
+
"""State associated with one connected peer."""
|
|
68
|
+
|
|
69
|
+
ws: ServerConnection
|
|
70
|
+
alias: str
|
|
71
|
+
armored_pubkey: str
|
|
72
|
+
fingerprint: str
|
|
73
|
+
room_id: str
|
|
74
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class PendingPeer:
|
|
79
|
+
"""A peer waiting for host approval to enter a group room."""
|
|
80
|
+
|
|
81
|
+
session: PeerSession
|
|
82
|
+
room_id: str
|
|
83
|
+
event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
84
|
+
approved: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
# Server #
|
|
89
|
+
# --------------------------------------------------------------------------- #
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class StealthServer:
|
|
93
|
+
"""WebSocket host implementing the stealth-message protocol.
|
|
94
|
+
|
|
95
|
+
Supports multiple rooms:
|
|
96
|
+
- 1-on-1 rooms (default): exactly one peer; second attempt gets 4006.
|
|
97
|
+
- Group rooms: multiple peers; new peers wait for host approval.
|
|
98
|
+
|
|
99
|
+
Callbacks
|
|
100
|
+
---------
|
|
101
|
+
on_peer_connected(alias, fingerprint, room_id)
|
|
102
|
+
on_message(alias, plaintext, room_id)
|
|
103
|
+
on_peer_disconnected(alias, room_id)
|
|
104
|
+
on_join_request(alias, fingerprint, room_id) — group rooms only
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
alias: str,
|
|
110
|
+
armored_privkey: str,
|
|
111
|
+
passphrase: str,
|
|
112
|
+
rooms: list[str] | None = None,
|
|
113
|
+
group_rooms: list[str] | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._alias: str = alias[:64]
|
|
116
|
+
self._privkey = load_private_key(armored_privkey, passphrase)
|
|
117
|
+
self._passphrase: str = passphrase
|
|
118
|
+
self._armored_pubkey: str = str(self._privkey.pubkey)
|
|
119
|
+
|
|
120
|
+
# Allowed rooms: None → accept any name; set → only those names.
|
|
121
|
+
self._allowed_rooms: set[str] | None = (
|
|
122
|
+
set(rooms) if rooms is not None else None
|
|
123
|
+
)
|
|
124
|
+
# Group rooms allow multiple peers (with host approval).
|
|
125
|
+
self._group_rooms: set[str] = set(group_rooms or [])
|
|
126
|
+
|
|
127
|
+
# room_id → list[PeerSession] (max 1 for 1:1 rooms, N for group rooms)
|
|
128
|
+
self._rooms: dict[str, list[PeerSession]] = {}
|
|
129
|
+
# Pending join requests keyed by session id.
|
|
130
|
+
self._pending: dict[str, PendingPeer] = {}
|
|
131
|
+
# alias → room_id for host-initiated moves (bypass approval).
|
|
132
|
+
self._pre_approved: dict[str, str] = {}
|
|
133
|
+
|
|
134
|
+
self._ws_server: Any = None
|
|
135
|
+
self._server_task: asyncio.Task[None] | None = None
|
|
136
|
+
self._stop_event: asyncio.Event | None = None
|
|
137
|
+
self._started_event: asyncio.Event | None = None
|
|
138
|
+
|
|
139
|
+
# Public callbacks — set before calling start().
|
|
140
|
+
self.on_peer_connected: Callable[[str, str, str], Awaitable[None]] | None = None
|
|
141
|
+
self.on_message: Callable[[str, str, str], Awaitable[None]] | None = None
|
|
142
|
+
self.on_peer_disconnected: Callable[[str, str], Awaitable[None]] | None = None
|
|
143
|
+
self.on_join_request: Callable[[str, str, str], Awaitable[None]] | None = None
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------ #
|
|
146
|
+
# Public API #
|
|
147
|
+
# ------------------------------------------------------------------ #
|
|
148
|
+
|
|
149
|
+
async def start(self, host: str = "localhost", port: int = 0) -> None:
|
|
150
|
+
self._stop_event = asyncio.Event()
|
|
151
|
+
self._started_event = asyncio.Event()
|
|
152
|
+
self._server_task = asyncio.create_task(
|
|
153
|
+
self._run(host, port), name="stealth-server"
|
|
154
|
+
)
|
|
155
|
+
await self._started_event.wait()
|
|
156
|
+
|
|
157
|
+
async def stop(self) -> None:
|
|
158
|
+
if self._stop_event:
|
|
159
|
+
self._stop_event.set()
|
|
160
|
+
if self._server_task:
|
|
161
|
+
try:
|
|
162
|
+
await self._server_task
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def port(self) -> int:
|
|
168
|
+
if self._ws_server is None:
|
|
169
|
+
raise RuntimeError("Server has not been started yet")
|
|
170
|
+
return self._ws_server.sockets[0].getsockname()[1]
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def connected_peers(self) -> list[str]:
|
|
174
|
+
"""Aliases of all connected peers across all rooms."""
|
|
175
|
+
return [p.alias for peers in self._rooms.values() for p in peers]
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def room_peers(self) -> dict[str, list[str] | None]:
|
|
179
|
+
"""Map of room_id → list of peer aliases (``None`` if empty)."""
|
|
180
|
+
result: dict[str, list[str] | None] = {}
|
|
181
|
+
if self._allowed_rooms is not None:
|
|
182
|
+
for r in self._allowed_rooms:
|
|
183
|
+
result[r] = None
|
|
184
|
+
for room_id, peers in self._rooms.items():
|
|
185
|
+
result[room_id] = [p.alias for p in peers] if peers else None
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
def add_room(self, room_id: str, group: bool = False) -> None:
|
|
189
|
+
"""Add a new room (or convert existing) at runtime."""
|
|
190
|
+
room_id = room_id[:64]
|
|
191
|
+
if self._allowed_rooms is not None:
|
|
192
|
+
self._allowed_rooms.add(room_id)
|
|
193
|
+
if group:
|
|
194
|
+
self._group_rooms.add(room_id)
|
|
195
|
+
|
|
196
|
+
def make_group_room(self, room_id: str) -> None:
|
|
197
|
+
"""Convert a room to group mode (multiple peers, host approval required)."""
|
|
198
|
+
self._group_rooms.add(room_id)
|
|
199
|
+
if self._allowed_rooms is not None:
|
|
200
|
+
self._allowed_rooms.add(room_id)
|
|
201
|
+
# Notify all connected peers of the updated group room list.
|
|
202
|
+
asyncio.get_running_loop().create_task(self._broadcast_roomlist())
|
|
203
|
+
|
|
204
|
+
def is_group_room(self, room_id: str) -> bool:
|
|
205
|
+
return room_id in self._group_rooms
|
|
206
|
+
|
|
207
|
+
def approve_join(self, alias: str) -> None:
|
|
208
|
+
"""Approve a pending join request by peer alias."""
|
|
209
|
+
for entry in self._pending.values():
|
|
210
|
+
if entry.session.alias == alias:
|
|
211
|
+
entry.approved = True
|
|
212
|
+
entry.event.set()
|
|
213
|
+
return
|
|
214
|
+
raise ValueError(f"No pending join request from {alias!r}")
|
|
215
|
+
|
|
216
|
+
def deny_join(self, alias: str) -> None:
|
|
217
|
+
"""Deny a pending join request by peer alias."""
|
|
218
|
+
for entry in self._pending.values():
|
|
219
|
+
if entry.session.alias == alias:
|
|
220
|
+
entry.approved = False
|
|
221
|
+
entry.event.set()
|
|
222
|
+
return
|
|
223
|
+
raise ValueError(f"No pending join request from {alias!r}")
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def pending_requests(self) -> list[tuple[str, str, str]]:
|
|
227
|
+
"""List of (alias, fingerprint, room_id) for pending join requests."""
|
|
228
|
+
return [
|
|
229
|
+
(e.session.alias, e.session.fingerprint, e.room_id)
|
|
230
|
+
for e in self._pending.values()
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
async def kick_peer(self, alias: str, reason: str = "disconnected by host") -> None:
|
|
234
|
+
"""Send a ``kick`` frame to a peer and close their connection.
|
|
235
|
+
|
|
236
|
+
The peer's client will display ``reason`` and close its end.
|
|
237
|
+
The server closes its WebSocket right after sending the frame.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
ValueError: if no peer with ``alias`` is currently connected.
|
|
241
|
+
"""
|
|
242
|
+
peer: PeerSession | None = None
|
|
243
|
+
for peers in self._rooms.values():
|
|
244
|
+
for p in peers:
|
|
245
|
+
if p.alias == alias:
|
|
246
|
+
peer = p
|
|
247
|
+
break
|
|
248
|
+
if peer is not None:
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
if peer is None:
|
|
252
|
+
raise ValueError(f"No connected peer with alias {alias!r}")
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
await peer.ws.send(json.dumps({"type": "kick", "reason": reason}))
|
|
256
|
+
except websockets.exceptions.ConnectionClosed:
|
|
257
|
+
pass
|
|
258
|
+
await peer.ws.close()
|
|
259
|
+
|
|
260
|
+
async def move_peer(self, alias: str, target_room: str) -> None:
|
|
261
|
+
"""Send a ``move`` message to a peer, pre-approving them for ``target_room``.
|
|
262
|
+
|
|
263
|
+
The target room is automatically converted to a group room if it
|
|
264
|
+
already has a peer.
|
|
265
|
+
"""
|
|
266
|
+
target_room = target_room[:64]
|
|
267
|
+
# Find the peer.
|
|
268
|
+
peer: PeerSession | None = None
|
|
269
|
+
for peers in self._rooms.values():
|
|
270
|
+
for p in peers:
|
|
271
|
+
if p.alias == alias:
|
|
272
|
+
peer = p
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
if peer is None:
|
|
276
|
+
raise ValueError(f"No connected peer with alias {alias!r}")
|
|
277
|
+
|
|
278
|
+
# If target room already has peers, make it a group room.
|
|
279
|
+
if self._rooms.get(target_room):
|
|
280
|
+
self.make_group_room(target_room)
|
|
281
|
+
|
|
282
|
+
# Pre-approve this alias for the target room (bypasses approval prompt).
|
|
283
|
+
self._pre_approved[alias] = target_room
|
|
284
|
+
|
|
285
|
+
# Ensure the target room is accessible.
|
|
286
|
+
if self._allowed_rooms is not None:
|
|
287
|
+
self._allowed_rooms.add(target_room)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
await peer.ws.send(
|
|
291
|
+
json.dumps({"type": "move", "room": target_room})
|
|
292
|
+
)
|
|
293
|
+
except websockets.exceptions.ConnectionClosed:
|
|
294
|
+
self._pre_approved.pop(alias, None)
|
|
295
|
+
raise
|
|
296
|
+
|
|
297
|
+
async def broadcast(self, plaintext: str) -> None:
|
|
298
|
+
"""Encrypt and send to every connected peer across all rooms."""
|
|
299
|
+
for peers in list(self._rooms.values()):
|
|
300
|
+
for peer in list(peers):
|
|
301
|
+
await self._send_message_to(peer, plaintext)
|
|
302
|
+
|
|
303
|
+
async def send_to_room(self, room_id: str, plaintext: str) -> None:
|
|
304
|
+
"""Encrypt and send to all peers in the given room."""
|
|
305
|
+
peers = self._rooms.get(room_id, [])
|
|
306
|
+
if not peers:
|
|
307
|
+
raise ValueError(f"No peer connected in room {room_id!r}")
|
|
308
|
+
for peer in list(peers):
|
|
309
|
+
await self._send_message_to(peer, plaintext)
|
|
310
|
+
|
|
311
|
+
async def send_to(self, alias: str, plaintext: str) -> None:
|
|
312
|
+
"""Encrypt and send to the peer with the given alias."""
|
|
313
|
+
for peers in self._rooms.values():
|
|
314
|
+
for peer in peers:
|
|
315
|
+
if peer.alias == alias:
|
|
316
|
+
await self._send_message_to(peer, plaintext)
|
|
317
|
+
return
|
|
318
|
+
raise ValueError(f"No connected peer with alias {alias!r}")
|
|
319
|
+
|
|
320
|
+
# ------------------------------------------------------------------ #
|
|
321
|
+
# Server lifecycle #
|
|
322
|
+
# ------------------------------------------------------------------ #
|
|
323
|
+
|
|
324
|
+
async def _run(self, host: str, port: int) -> None:
|
|
325
|
+
async with serve(
|
|
326
|
+
self._handle_connection, host, port, ping_interval=None
|
|
327
|
+
) as ws_server:
|
|
328
|
+
self._ws_server = ws_server
|
|
329
|
+
assert self._started_event is not None
|
|
330
|
+
self._started_event.set()
|
|
331
|
+
assert self._stop_event is not None
|
|
332
|
+
await self._stop_event.wait()
|
|
333
|
+
|
|
334
|
+
# ------------------------------------------------------------------ #
|
|
335
|
+
# Connection handler #
|
|
336
|
+
# ------------------------------------------------------------------ #
|
|
337
|
+
|
|
338
|
+
async def _handle_connection(self, ws: ServerConnection) -> None:
|
|
339
|
+
# Read the first frame to decide: listrooms query or normal hello.
|
|
340
|
+
try:
|
|
341
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=HANDSHAKE_TIMEOUT)
|
|
342
|
+
except asyncio.TimeoutError:
|
|
343
|
+
await self._safe_send_error(ws, 4005, "handshake timeout")
|
|
344
|
+
return
|
|
345
|
+
except websockets.exceptions.ConnectionClosed:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
first_msg: dict[str, Any] = json.loads(raw)
|
|
350
|
+
except (json.JSONDecodeError, TypeError):
|
|
351
|
+
await self._safe_send_error(ws, 4002, "invalid JSON")
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
# Handle lightweight room-discovery request (no auth needed).
|
|
355
|
+
if first_msg.get("type") == "listrooms":
|
|
356
|
+
await self._handle_listrooms(ws)
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
peer: PeerSession | None = None
|
|
360
|
+
pending_entry: PendingPeer | None = None
|
|
361
|
+
try:
|
|
362
|
+
peer, pending_entry = await asyncio.wait_for(
|
|
363
|
+
self._do_handshake(ws, first_msg=first_msg), timeout=HANDSHAKE_TIMEOUT
|
|
364
|
+
)
|
|
365
|
+
except asyncio.TimeoutError:
|
|
366
|
+
await self._safe_send_error(ws, 4005, "handshake timeout")
|
|
367
|
+
return
|
|
368
|
+
except ProtocolError as exc:
|
|
369
|
+
await self._safe_send_error(ws, exc.code, str(exc))
|
|
370
|
+
return
|
|
371
|
+
except websockets.exceptions.ConnectionClosed:
|
|
372
|
+
return
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
logger.debug("Handshake error: %s", exc)
|
|
375
|
+
await self._safe_send_error(ws, 4002, "handshake error")
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
# If this is a group-room join that requires host approval, wait here —
|
|
379
|
+
# outside the HANDSHAKE_TIMEOUT so the host has the full JOIN_REQUEST_TIMEOUT.
|
|
380
|
+
if pending_entry is not None:
|
|
381
|
+
try:
|
|
382
|
+
await asyncio.wait_for(
|
|
383
|
+
pending_entry.event.wait(), timeout=JOIN_REQUEST_TIMEOUT
|
|
384
|
+
)
|
|
385
|
+
except asyncio.TimeoutError:
|
|
386
|
+
del self._pending[peer.id]
|
|
387
|
+
await self._safe_send_error(ws, 4008, "join request timed out")
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
del self._pending[peer.id]
|
|
391
|
+
|
|
392
|
+
if not pending_entry.approved:
|
|
393
|
+
await self._safe_send_error(ws, 4008, "join request denied by host")
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
await ws.send(json.dumps({"type": "approved"}))
|
|
398
|
+
except websockets.exceptions.ConnectionClosed:
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
self._rooms.setdefault(peer.room_id, []).append(peer)
|
|
402
|
+
logger.info("Peer connected: %s fp=%s room=%s", peer.alias, peer.fingerprint, peer.room_id)
|
|
403
|
+
|
|
404
|
+
# Send the current group room list to the newly connected peer.
|
|
405
|
+
await self._send_roomlist_to(peer)
|
|
406
|
+
|
|
407
|
+
# In group rooms, broadcast updated peer list to all peers in the room.
|
|
408
|
+
if peer.room_id in self._group_rooms:
|
|
409
|
+
await self._broadcast_peerlist(peer.room_id)
|
|
410
|
+
|
|
411
|
+
if self.on_peer_connected:
|
|
412
|
+
await self.on_peer_connected(peer.alias, peer.fingerprint, peer.room_id)
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
async for raw in ws:
|
|
416
|
+
await self._dispatch(ws, peer, raw)
|
|
417
|
+
except websockets.exceptions.ConnectionClosed:
|
|
418
|
+
pass
|
|
419
|
+
finally:
|
|
420
|
+
room_list = self._rooms.get(peer.room_id, [])
|
|
421
|
+
if peer in room_list:
|
|
422
|
+
room_list.remove(peer)
|
|
423
|
+
if not room_list:
|
|
424
|
+
self._rooms.pop(peer.room_id, None)
|
|
425
|
+
logger.info("Peer disconnected: %s room=%s", peer.alias, peer.room_id)
|
|
426
|
+
# Broadcast updated peer list after someone leaves the group room.
|
|
427
|
+
if peer.room_id in self._group_rooms:
|
|
428
|
+
await self._broadcast_peerlist(peer.room_id)
|
|
429
|
+
if self.on_peer_disconnected:
|
|
430
|
+
await self.on_peer_disconnected(peer.alias, peer.room_id)
|
|
431
|
+
|
|
432
|
+
# ------------------------------------------------------------------ #
|
|
433
|
+
# Handshake — §1.1 #
|
|
434
|
+
# ------------------------------------------------------------------ #
|
|
435
|
+
|
|
436
|
+
async def _do_handshake(
|
|
437
|
+
self, ws: ServerConnection, *, first_msg: dict[str, Any] | None = None
|
|
438
|
+
) -> tuple[PeerSession, PendingPeer | None]:
|
|
439
|
+
if first_msg is not None:
|
|
440
|
+
msg = first_msg
|
|
441
|
+
else:
|
|
442
|
+
raw = await ws.recv()
|
|
443
|
+
try:
|
|
444
|
+
msg = json.loads(raw)
|
|
445
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
446
|
+
raise ProtocolError("malformed hello: invalid JSON", 4002) from exc
|
|
447
|
+
|
|
448
|
+
if msg.get("type") != "hello":
|
|
449
|
+
raise ProtocolError(f"expected hello, got {msg.get('type')!r}", 4002)
|
|
450
|
+
|
|
451
|
+
if str(msg.get("version")) != PROTOCOL_VERSION:
|
|
452
|
+
raise ProtocolError(
|
|
453
|
+
f"unsupported protocol version {msg.get('version')!r}", 4001
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
for required in ("alias", "pubkey"):
|
|
457
|
+
if not msg.get(required):
|
|
458
|
+
raise ProtocolError(f"hello missing field: {required!r}", 4002)
|
|
459
|
+
|
|
460
|
+
room_id = str(msg.get("room") or "default")[:64] or "default"
|
|
461
|
+
|
|
462
|
+
if self._allowed_rooms is not None and room_id not in self._allowed_rooms:
|
|
463
|
+
raise ProtocolError(f"room {room_id!r} not found on this server", 4007)
|
|
464
|
+
|
|
465
|
+
peer_alias = str(msg["alias"])[:64]
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
peer_armored = base64.urlsafe_b64decode(
|
|
469
|
+
msg["pubkey"].encode("ascii") + b"=="
|
|
470
|
+
).decode("utf-8")
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
raise ProtocolError("invalid pubkey encoding in hello", 4002) from exc
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
peer_fp = get_fingerprint(peer_armored)
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
raise ProtocolError("invalid pubkey in hello", 4002) from exc
|
|
478
|
+
|
|
479
|
+
existing = self._rooms.get(room_id, [])
|
|
480
|
+
is_group = room_id in self._group_rooms
|
|
481
|
+
is_pre_approved = self._pre_approved.get(peer_alias) == room_id
|
|
482
|
+
|
|
483
|
+
if existing and not is_group and not is_pre_approved:
|
|
484
|
+
# 1-on-1 room already occupied → reject.
|
|
485
|
+
raise ProtocolError(f"room {room_id!r} is already occupied", 4006)
|
|
486
|
+
|
|
487
|
+
if existing and is_pre_approved:
|
|
488
|
+
# Host-initiated move: remove from pre-approval list and proceed.
|
|
489
|
+
self._pre_approved.pop(peer_alias, None)
|
|
490
|
+
|
|
491
|
+
# Send our hello.
|
|
492
|
+
await ws.send(
|
|
493
|
+
json.dumps({
|
|
494
|
+
"type": "hello",
|
|
495
|
+
"version": PROTOCOL_VERSION,
|
|
496
|
+
"alias": self._alias,
|
|
497
|
+
"pubkey": base64.urlsafe_b64encode(
|
|
498
|
+
self._armored_pubkey.encode("utf-8")
|
|
499
|
+
).decode("ascii"),
|
|
500
|
+
})
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
peer = PeerSession(
|
|
504
|
+
ws=ws,
|
|
505
|
+
alias=peer_alias,
|
|
506
|
+
armored_pubkey=peer_armored,
|
|
507
|
+
fingerprint=peer_fp,
|
|
508
|
+
room_id=room_id,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
if is_group and not is_pre_approved:
|
|
512
|
+
# Group room → pending approval flow for every peer (including the first).
|
|
513
|
+
# We send the server hello FIRST so the client knows who the host is,
|
|
514
|
+
# then send `pending` so the client can display a waiting message.
|
|
515
|
+
pending = PendingPeer(session=peer, room_id=room_id)
|
|
516
|
+
self._pending[peer.id] = pending
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
await ws.send(json.dumps({"type": "pending"}))
|
|
520
|
+
except websockets.exceptions.ConnectionClosed:
|
|
521
|
+
del self._pending[peer.id]
|
|
522
|
+
raise
|
|
523
|
+
|
|
524
|
+
# Notify the host UI.
|
|
525
|
+
if self.on_join_request:
|
|
526
|
+
await self.on_join_request(peer_alias, peer_fp, room_id)
|
|
527
|
+
|
|
528
|
+
# Return the pending entry so _handle_connection can wait for approval
|
|
529
|
+
# outside the HANDSHAKE_TIMEOUT.
|
|
530
|
+
return peer, pending
|
|
531
|
+
|
|
532
|
+
return peer, None
|
|
533
|
+
|
|
534
|
+
# ------------------------------------------------------------------ #
|
|
535
|
+
# Message dispatch — §2, §3, §4 #
|
|
536
|
+
# ------------------------------------------------------------------ #
|
|
537
|
+
|
|
538
|
+
async def _dispatch(
|
|
539
|
+
self, ws: ServerConnection, peer: PeerSession, raw: str | bytes
|
|
540
|
+
) -> None:
|
|
541
|
+
try:
|
|
542
|
+
msg: dict[str, Any] = json.loads(raw)
|
|
543
|
+
except (json.JSONDecodeError, TypeError):
|
|
544
|
+
await self._safe_send_error(ws, 4002, "invalid JSON")
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
msg_type = msg.get("type")
|
|
548
|
+
|
|
549
|
+
if msg_type == "message":
|
|
550
|
+
await self._handle_chat(ws, peer, msg)
|
|
551
|
+
elif msg_type == "ping":
|
|
552
|
+
await ws.send(json.dumps({"type": "pong"}))
|
|
553
|
+
elif msg_type == "bye":
|
|
554
|
+
await ws.close()
|
|
555
|
+
elif msg_type == "pong":
|
|
556
|
+
pass
|
|
557
|
+
elif msg_type == "error":
|
|
558
|
+
logger.warning(
|
|
559
|
+
"Error from peer %s (room=%s): code=%s reason=%s",
|
|
560
|
+
peer.alias, peer.room_id, msg.get("code"), msg.get("reason"),
|
|
561
|
+
)
|
|
562
|
+
elif msg_type is None:
|
|
563
|
+
await self._safe_send_error(ws, 4002, "missing 'type' field")
|
|
564
|
+
else:
|
|
565
|
+
logger.debug("Ignoring unknown message type %r from %s", msg_type, peer.alias)
|
|
566
|
+
|
|
567
|
+
async def _handle_chat(
|
|
568
|
+
self, ws: ServerConnection, peer: PeerSession, msg: dict[str, Any]
|
|
569
|
+
) -> None:
|
|
570
|
+
for required in ("id", "payload", "timestamp"):
|
|
571
|
+
if required not in msg:
|
|
572
|
+
await self._safe_send_error(ws, 4002, f"message missing field: {required!r}")
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
with self._privkey.unlock(self._passphrase):
|
|
577
|
+
plaintext = decrypt(msg["payload"], self._privkey, peer.armored_pubkey)
|
|
578
|
+
except SignatureError:
|
|
579
|
+
await self._safe_send_error(ws, 4003, "PGP signature invalid")
|
|
580
|
+
return
|
|
581
|
+
except Exception as exc:
|
|
582
|
+
logger.debug("Decryption error from %s: %s", peer.alias, exc)
|
|
583
|
+
await self._safe_send_error(ws, 4004, "decryption failed")
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
logger.debug("Message from %s (room=%s): %d chars", peer.alias, peer.room_id, len(plaintext))
|
|
587
|
+
|
|
588
|
+
if self.on_message:
|
|
589
|
+
await self.on_message(peer.alias, plaintext, peer.room_id)
|
|
590
|
+
|
|
591
|
+
# In group rooms, forward the message to all other peers in the room.
|
|
592
|
+
if peer.room_id in self._group_rooms:
|
|
593
|
+
for other in list(self._rooms.get(peer.room_id, [])):
|
|
594
|
+
if other.id != peer.id:
|
|
595
|
+
await self._send_message_to(other, plaintext, sender=peer.alias)
|
|
596
|
+
|
|
597
|
+
# ------------------------------------------------------------------ #
|
|
598
|
+
# Outbound helpers #
|
|
599
|
+
# ------------------------------------------------------------------ #
|
|
600
|
+
|
|
601
|
+
def _rooms_info(self) -> list[dict[str, Any]]:
|
|
602
|
+
"""Return room info for discovery — no peer names, only counts."""
|
|
603
|
+
all_rooms: set[str] = set(self._allowed_rooms or []) | set(self._rooms.keys())
|
|
604
|
+
result = []
|
|
605
|
+
for room_id in sorted(all_rooms):
|
|
606
|
+
is_group = room_id in self._group_rooms
|
|
607
|
+
peer_count = len(self._rooms.get(room_id, []))
|
|
608
|
+
if is_group:
|
|
609
|
+
result.append({"id": room_id, "kind": "group", "peers": peer_count})
|
|
610
|
+
else:
|
|
611
|
+
result.append(
|
|
612
|
+
{"id": room_id, "kind": "1:1", "peers": peer_count, "available": peer_count == 0}
|
|
613
|
+
)
|
|
614
|
+
return result
|
|
615
|
+
|
|
616
|
+
async def _handle_listrooms(self, ws: ServerConnection) -> None:
|
|
617
|
+
"""Respond to a listrooms query and close the connection."""
|
|
618
|
+
try:
|
|
619
|
+
await ws.send(json.dumps({"type": "roomsinfo", "rooms": self._rooms_info()}))
|
|
620
|
+
await ws.close()
|
|
621
|
+
except websockets.exceptions.ConnectionClosed:
|
|
622
|
+
pass
|
|
623
|
+
|
|
624
|
+
async def _send_roomlist_to(self, peer: PeerSession) -> None:
|
|
625
|
+
"""Send the current group room list to a single peer."""
|
|
626
|
+
frame = json.dumps({"type": "roomlist", "groups": sorted(self._group_rooms)})
|
|
627
|
+
try:
|
|
628
|
+
await peer.ws.send(frame)
|
|
629
|
+
except websockets.exceptions.ConnectionClosed:
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
async def _broadcast_peerlist(self, room_id: str) -> None:
|
|
633
|
+
"""Send the updated peer list to all peers in a group room.
|
|
634
|
+
|
|
635
|
+
Each peer receives the aliases and fingerprints of all OTHER peers in
|
|
636
|
+
the room (not themselves, since they already know their own identity).
|
|
637
|
+
"""
|
|
638
|
+
peers_in_room = list(self._rooms.get(room_id, []))
|
|
639
|
+
for peer in peers_in_room:
|
|
640
|
+
others = [
|
|
641
|
+
{"alias": p.alias, "fingerprint": p.fingerprint}
|
|
642
|
+
for p in peers_in_room
|
|
643
|
+
if p.id != peer.id
|
|
644
|
+
]
|
|
645
|
+
frame = json.dumps({"type": "peerlist", "peers": others})
|
|
646
|
+
try:
|
|
647
|
+
await peer.ws.send(frame)
|
|
648
|
+
except websockets.exceptions.ConnectionClosed:
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
async def _broadcast_roomlist(self) -> None:
|
|
652
|
+
"""Send the updated group room list to all connected peers."""
|
|
653
|
+
frame = json.dumps({"type": "roomlist", "groups": sorted(self._group_rooms)})
|
|
654
|
+
for peers in list(self._rooms.values()):
|
|
655
|
+
for peer in list(peers):
|
|
656
|
+
try:
|
|
657
|
+
await peer.ws.send(frame)
|
|
658
|
+
except websockets.exceptions.ConnectionClosed:
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
async def _send_message_to(
|
|
662
|
+
self, peer: PeerSession, plaintext: str, sender: str | None = None
|
|
663
|
+
) -> None:
|
|
664
|
+
try:
|
|
665
|
+
with self._privkey.unlock(self._passphrase):
|
|
666
|
+
payload = encrypt(plaintext, peer.armored_pubkey, self._privkey)
|
|
667
|
+
except Exception as exc:
|
|
668
|
+
logger.error("Failed to encrypt for %s: %s", peer.alias, exc)
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
frame: dict[str, Any] = {
|
|
672
|
+
"type": "message",
|
|
673
|
+
"id": str(uuid.uuid4()),
|
|
674
|
+
"payload": payload,
|
|
675
|
+
"timestamp": int(time.time() * 1000),
|
|
676
|
+
}
|
|
677
|
+
if sender is not None:
|
|
678
|
+
frame["sender"] = sender
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
await peer.ws.send(json.dumps(frame))
|
|
682
|
+
except websockets.exceptions.ConnectionClosed:
|
|
683
|
+
logger.debug("Connection closed before message could be sent to %s", peer.alias)
|
|
684
|
+
|
|
685
|
+
@staticmethod
|
|
686
|
+
async def _safe_send_error(ws: ServerConnection, code: int, reason: str) -> None:
|
|
687
|
+
try:
|
|
688
|
+
await ws.send(json.dumps({"type": "error", "code": code, "reason": reason}))
|
|
689
|
+
except websockets.exceptions.ConnectionClosed:
|
|
690
|
+
pass
|
|
File without changes
|