dark-chat 2.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,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: dark-chat
3
+ Version: 2.1.0
4
+ Summary: Minimal two-person encrypted terminal chat and WebSocket relay
5
+ Project-URL: Repository, https://github.com/iZubayr/dark-terminal-chat
6
+ Project-URL: Issues, https://github.com/iZubayr/dark-terminal-chat/issues
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Communications :: Chat
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: cryptography<50,>=44
14
+ Requires-Dist: websockets<18,>=15
15
+ Requires-Dist: prompt-toolkit<4,>=3.0.48
16
+
17
+ # Dark Terminal Chat
18
+
19
+ A plain, English terminal chat for two people. No banner, logo, animation, or forced color. Python 3.10 or newer is required. The client and WebSocket server install from the same Python package.
20
+
21
+ Version 2 uses a new session protocol. Use version 2 clients and servers together. Source: [GitHub](https://github.com/iZubayr/dark-terminal-chat).
22
+
23
+ ## Install
24
+
25
+ Install from [PyPI](https://pypi.org/project/dark-chat/):
26
+
27
+ ~~~sh
28
+ pip install dark-chat
29
+ ~~~
30
+
31
+ Create a chat:
32
+
33
+ ~~~sh
34
+ dark-chat --new
35
+ ~~~
36
+
37
+ Join a chat:
38
+
39
+ ~~~sh
40
+ dark-chat --chat
41
+ ~~~
42
+
43
+ Enter your name when asked. The creator receives a code and shares it with the peer. The peer enters that code when asked. Both commands connect to the hosted server automatically.
44
+
45
+ For development, install from this folder on Windows:
46
+
47
+ ~~~powershell
48
+ python -m venv .venv
49
+ .\.venv\Scripts\Activate.ps1
50
+ python -m pip install .
51
+ ~~~
52
+
53
+ On Linux or macOS:
54
+
55
+ ~~~sh
56
+ python3 -m venv .venv
57
+ . .venv/bin/activate
58
+ python -m pip install .
59
+ ~~~
60
+
61
+ The package provides two commands: dark-chat and dark-chat-server. You can also use python -m dark_terminal_chat and python -m dark_terminal_chat.server.
62
+
63
+ ## Local use
64
+
65
+ Open three terminals with the Python environment activated.
66
+
67
+ ~~~sh
68
+ # Server
69
+ dark-chat-server
70
+
71
+ # Creator
72
+ dark-chat --server ws://127.0.0.1:8080/ws --new --name elliot
73
+
74
+ # Peer, in another terminal
75
+ dark-chat --server ws://127.0.0.1:8080/ws --chat --name whiterose
76
+ ~~~
77
+
78
+ The creator receives a line starting with "Code:". The peer pastes that code at "Code:". The code is hidden while entering it. If no name is supplied, the program asks "Name:". Running dark-chat without either mode asks "Create or join? [c/j]:".
79
+
80
+ The interface contains only prompts, messages, and relevant connection/error notices. Example:
81
+
82
+ ~~~text
83
+ Code: <invite code>
84
+ Waiting for peer.
85
+ whiterose joined.
86
+ whiterose> Hello.
87
+ elliot>
88
+ ~~~
89
+
90
+ On Windows, chat.cmd and server.cmd use this folder's .venv when available.
91
+
92
+ ## Session rules
93
+
94
+ - Each chat has two participant slots. A third participant is refused.
95
+ - Leaving with /exit or Ctrl+C releases the participant's slot immediately while connected.
96
+ - When both participants leave, the room is removed. Joining with its old code fails; create a new chat for a new code.
97
+ - A dropped connection reserves the same participant's slot for 60 seconds. Only that participant's in-memory resume token can reclaim it.
98
+ - The client tries to reconnect for up to 60 seconds. When a server restarts, room state is lost and a new chat is needed.
99
+ - Sending is paused while the peer is offline. The draft remains editable.
100
+ - Messages are not queued or automatically resent. If receipt isn't confirmed, the draft is kept and a short notice is shown. A lost receipt can mean the peer already saw the message; check before manually resending.
101
+ - Messages and drafts are not written to files. Old messages are not replayed after reconnecting.
102
+
103
+ ## Internet use
104
+
105
+ The deployed server is available at wss://zubayr.alwaysdata.net/dark-chat/ws:
106
+
107
+ ~~~sh
108
+ dark-chat --new
109
+ dark-chat --chat
110
+ ~~~
111
+
112
+ Share the code with the peer. To use your own relay, pass --server wss://YOUR_SERVER/ws. Use wss:// for internet connections. TLS certificates are checked; --ca ca.pem supports a private certificate authority.
113
+
114
+ DARK_CHAT_SERVER can override the default relay. --server overrides that environment variable. DARK_CHAT_CODE is supported for non-interactive clients; interactive clients ask for the code instead.
115
+
116
+ For local Wi-Fi, use --host 0.0.0.0 on the server and --server ws://LAN_IP:8080/ws --allow-insecure on clients.
117
+
118
+ [AlwaysData setup beside MediaHub](docs/alwaysdata.md).
119
+
120
+ ## Commands
121
+
122
+ | Command | Action |
123
+ | --- | --- |
124
+ | /help | List commands |
125
+ | /who | Show the connected participants whose names are known |
126
+ | /clear | Clear the screen |
127
+ | /exit or Ctrl+C | Leave |
128
+
129
+ ## Privacy
130
+
131
+ Messages and names are encrypted on the client with AES-256-GCM. Keys are derived from a random 32-byte invite code with HKDF-SHA256. The invite code is never sent to the relay. The relay sees opaque room/session identifiers, IP addresses, message sizes, timing, and delivery-control metadata.
132
+
133
+ An invite code is shared access, and a name is not verified identity. Keep the code private. The terminal scrollback may retain displayed messages and the creator's code; /clear is not secure erasure. There is no forward secrecy, anonymity guarantee, or independent security audit.
134
+
135
+ The relay holds only session metadata and temporary network buffers. There is no message history or application-level message queue. The default server limit is 128 connections/rooms, with two participant slots per room and a 40-packet limit per connection per 10 seconds. Run one relay process; separate instances do not share rooms.
136
+
137
+ ## Build and check
138
+
139
+ ~~~sh
140
+ python -m pip install -e . build twine
141
+ python -m unittest discover -s tests -v
142
+ python -m build
143
+ python -m twine check dist/*
144
+ ~~~
145
+
146
+ [Package distribution](docs/distribution.md).
147
+
148
+ GitHub Actions tests the installed wheel on Linux with Python 3.10/3.12 and on Windows with Python 3.12. After all jobs pass, it advances the deploy branch. AlwaysData checks that branch every five minutes and tests the candidate again before changing the running version. See [automatic updates](docs/alwaysdata.md#automatic-updates).
149
+
150
+ The earlier Node.js TCP experiment remains as reference source only. It does not support the current session protocol. The Python client/server is the application.
@@ -0,0 +1,10 @@
1
+ dark_terminal_chat/__init__.py,sha256=Tjp_C8ZOtL3SBxNSxtUk2lcIOpQ_gCUYBZk_0qFPFvg,69
2
+ dark_terminal_chat/__main__.py,sha256=dBYQQw4fGaTMLk7QksrvZzZEbDRD9TajoO8arFHMcLs,33
3
+ dark_terminal_chat/client.py,sha256=hpRLH6x52t8MY_urB7PoWThNgDGB84jN3cZe82rllDg,19410
4
+ dark_terminal_chat/protocol.py,sha256=XHBCZvaZm0-FOlLgAtBTT6HoQ8kpf6X4fAJB3uAzyAU,3697
5
+ dark_terminal_chat/server.py,sha256=hC5EnKYUquoRg69tARv164y2lkDWMc0-9Q6uTEvSXpo,10527
6
+ dark_chat-2.1.0.dist-info/METADATA,sha256=SwL4S3xVfFiAmOxiqV9QsoHXQi8PlZrUn4-ZMUxy7bA,6056
7
+ dark_chat-2.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ dark_chat-2.1.0.dist-info/entry_points.txt,sha256=qOA0m8GL1bvCXkx29FZlgllYXKFh6x6R91IUvxWfyIM,111
9
+ dark_chat-2.1.0.dist-info/top_level.txt,sha256=mm_1CFI5qdY-x85cSU8uEWqJXc9BfKaBzj-2qGT26Hw,19
10
+ dark_chat-2.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dark-chat = dark_terminal_chat.client:main
3
+ dark-chat-server = dark_terminal_chat.server:main
@@ -0,0 +1 @@
1
+ dark_terminal_chat
@@ -0,0 +1,3 @@
1
+ """Encrypted terminal chat over WebSocket."""
2
+
3
+ __version__ = "2.1.0"
@@ -0,0 +1,3 @@
1
+ from .client import main
2
+
3
+ main()
@@ -0,0 +1,406 @@
1
+ """Minimal English terminal client with a 60-second reconnect window."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import contextlib
6
+ import os
7
+ import re
8
+ import ssl
9
+ import sys
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from urllib.parse import urlsplit, urlunsplit
14
+
15
+ from cryptography.exceptions import InvalidTag
16
+ from prompt_toolkit import PromptSession, prompt
17
+ from prompt_toolkit.history import DummyHistory
18
+ from prompt_toolkit.patch_stdout import patch_stdout
19
+ from websockets.asyncio.client import connect
20
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake
21
+
22
+ from . import __version__
23
+ from .protocol import MAX_FRAME, MAX_TEXT, SESSION, decrypt, dumps, encrypt, new_code, parse, room_keys, safe_text, valid_message
24
+
25
+ RECONNECT_SECONDS = 60
26
+ DEFAULT_SERVER = "wss://zubayr.alwaysdata.net/dark-chat/ws"
27
+
28
+
29
+ class SessionError(Exception):
30
+ pass
31
+
32
+
33
+ def server_url(value, allow_insecure=False):
34
+ value = value.strip()
35
+ if any(ord(char) < 33 for char in value):
36
+ raise ValueError("Server address cannot contain spaces or control characters.")
37
+ url = urlsplit(value)
38
+ scheme = {"https": "wss", "http": "ws"}.get(url.scheme, url.scheme)
39
+ if (scheme not in {"ws", "wss"} or not url.hostname
40
+ or url.username is not None or url.password is not None or url.query or url.fragment):
41
+ raise ValueError("Use a server address such as wss://ACCOUNT.alwaysdata.net/dark-chat/ws.")
42
+ if url.port is not None and not 1 <= url.port <= 65535:
43
+ raise ValueError("Invalid server port.")
44
+ if scheme == "ws" and url.hostname not in {"localhost", "127.0.0.1", "::1"} and not allow_insecure:
45
+ raise ValueError("Use wss:// over the internet. For a local network, use --allow-insecure.")
46
+ return urlunsplit((scheme, url.netloc, url.path or "/ws", "", ""))
47
+
48
+
49
+ class Terminal:
50
+ def __init__(self):
51
+ self.interactive = sys.stdin.isatty() and sys.stdout.isatty()
52
+
53
+ def say(self, text):
54
+ print(safe_text(text), flush=True)
55
+
56
+ def ask(self, text, secret=False):
57
+ if not self.interactive:
58
+ raise ValueError("Use an interactive terminal, or provide --name and DARK_CHAT_CODE.")
59
+ return prompt(text, is_password=secret, history=DummyHistory()).strip()
60
+
61
+
62
+ class Chat:
63
+ def __init__(self, terminal, keys, name, reconnect_seconds=RECONNECT_SECONDS):
64
+ self.terminal = terminal
65
+ self.keys = keys
66
+ self.name = name
67
+ self.session = str(uuid.uuid4())
68
+ self.seq = 0
69
+ self.peers = {}
70
+ self.online = {self.session}
71
+ self.peer_ready = False
72
+ self.token = None
73
+ self.ws = None
74
+ self.finished = asyncio.Event()
75
+ self.pending = {}
76
+ self.draft = ""
77
+ self.reconnect_seconds = reconnect_seconds
78
+
79
+ def packet(self, kind, text="", **extra):
80
+ self.seq += 1
81
+ envelope = encrypt(self.keys, {"kind": kind, "name": self.name, "text": text,
82
+ "session": self.session, "seq": self.seq, **extra})
83
+ envelope["id"] = self.seq
84
+ envelope["receipt"] = kind == "chat"
85
+ return envelope
86
+
87
+ def fail_pending(self):
88
+ for future in self.pending.values():
89
+ if not future.done():
90
+ future.set_result(False)
91
+
92
+ async def receive(self, ws):
93
+ async for raw in ws:
94
+ frame = parse(raw)
95
+ if frame.get("type") == "presence":
96
+ online, reserved = frame.get("sessions"), frame.get("reserved")
97
+ if (not isinstance(online, list) or not isinstance(reserved, list)
98
+ or not 1 <= len(reserved) <= 2 or not 1 <= len(online) <= 2
99
+ or not all(isinstance(sid, str) and SESSION.fullmatch(sid) for sid in reserved + online)
100
+ or len(set(reserved)) != len(reserved) or len(set(online)) != len(online)
101
+ or not set(online) <= set(reserved) or self.session not in online):
102
+ raise SessionError("Invalid server response.")
103
+ new_online = set(online)
104
+ for sid in self.online - new_online:
105
+ peer = self.peers.get(sid)
106
+ if peer and peer["online"]:
107
+ action = "disconnected" if sid in reserved else "left"
108
+ self.terminal.say(f"{peer['name']} {action}.")
109
+ peer["online"] = False
110
+ if sid not in reserved:
111
+ self.peers.pop(sid, None)
112
+ if len(new_online) == 1:
113
+ if self.peer_ready:
114
+ self.fail_pending()
115
+ self.peer_ready = False
116
+ if not self.peers:
117
+ self.terminal.say("Waiting for peer.")
118
+ newcomers = new_online - self.online
119
+ self.online = new_online
120
+ if newcomers:
121
+ await ws.send(dumps(self.packet("hello")))
122
+ continue
123
+ if frame.get("type") == "not_delivered":
124
+ if type(frame.get("id")) is not int:
125
+ raise SessionError("Invalid server response.")
126
+ future = self.pending.get(frame.get("id"))
127
+ if future and not future.done():
128
+ future.set_result(False)
129
+ self.peer_ready = False
130
+ continue
131
+ if frame.get("type") != "message":
132
+ raise SessionError("Invalid server response.")
133
+ try:
134
+ message = decrypt(self.keys, frame)
135
+ except (ValueError, InvalidTag, UnicodeError):
136
+ self.terminal.say("Invalid encrypted message ignored.")
137
+ continue
138
+ if (not valid_message(message) or message["session"] != frame.get("sender")
139
+ or message["seq"] != frame.get("id")
140
+ or message["session"] not in self.online or message["session"] == self.session):
141
+ continue
142
+ sid = message["session"]
143
+ old = self.peers.get(sid)
144
+ if old and old["seq"] >= message["seq"]:
145
+ continue
146
+ name = safe_text(message["name"])
147
+ self.peers[sid] = {"name": name, "seq": message["seq"], "online": True}
148
+ self.peer_ready = True
149
+ if message["kind"] == "hello":
150
+ if not old:
151
+ self.terminal.say(f"{name} joined.")
152
+ elif not old["online"]:
153
+ self.terminal.say(f"{name} reconnected.")
154
+ elif message["kind"] == "chat":
155
+ self.terminal.say(f"{name}> {safe_text(message['text'])}")
156
+ await ws.send(dumps(self.packet("ack", ack=message["seq"])))
157
+ elif message["kind"] == "ack":
158
+ future = self.pending.get(message["ack"])
159
+ if future and not future.done():
160
+ future.set_result(True)
161
+ raise ConnectionError("Connection closed.")
162
+
163
+ async def send_message(self, text):
164
+ if self.ws is None or not self.peer_ready:
165
+ self.terminal.say("Sending paused. Draft kept.")
166
+ return False
167
+ envelope = self.packet("chat", text)
168
+ message_id = envelope["id"]
169
+ future = asyncio.get_running_loop().create_future()
170
+ self.pending[message_id] = future
171
+ try:
172
+ await self.ws.send(dumps(envelope))
173
+ confirmed = await asyncio.wait_for(future, timeout=8)
174
+ except (ConnectionClosed, OSError, TimeoutError, asyncio.TimeoutError):
175
+ confirmed = False
176
+ finally:
177
+ self.pending.pop(message_id, None)
178
+ if not confirmed:
179
+ self.terminal.say("Delivery not confirmed. Draft kept.")
180
+ return confirmed
181
+
182
+ async def send_input(self):
183
+ terminal = self.terminal
184
+ if terminal.interactive:
185
+ session = PromptSession(history=DummyHistory())
186
+
187
+ async def read():
188
+ return await session.prompt_async(f"{self.name}> ", default=self.draft)
189
+ else:
190
+ # A daemon reader allows Windows to exit while stdin is blocked.
191
+ queue = asyncio.Queue()
192
+ loop = asyncio.get_running_loop()
193
+ stopped = threading.Event()
194
+
195
+ def feed():
196
+ for raw in sys.stdin:
197
+ if stopped.is_set():
198
+ return
199
+ try:
200
+ loop.call_soon_threadsafe(queue.put_nowait, raw.rstrip("\r\n"))
201
+ except RuntimeError:
202
+ return
203
+ if not stopped.is_set():
204
+ with contextlib.suppress(RuntimeError):
205
+ loop.call_soon_threadsafe(queue.put_nowait, None)
206
+
207
+ threading.Thread(target=feed, daemon=True).start()
208
+
209
+ async def read():
210
+ value = await queue.get()
211
+ if value is None:
212
+ raise EOFError
213
+ return value
214
+ try:
215
+ while not self.finished.is_set():
216
+ try:
217
+ text = safe_text(await read())
218
+ except (EOFError, KeyboardInterrupt):
219
+ return
220
+ command = text.strip()
221
+ if not command:
222
+ self.draft = ""
223
+ continue
224
+ if command in {"/exit", "/quit"}:
225
+ return
226
+ if command == "/help":
227
+ terminal.say("/who /clear /exit")
228
+ elif command == "/who":
229
+ names = [peer["name"] for sid, peer in self.peers.items()
230
+ if sid in self.online and peer["online"]]
231
+ terminal.say(f"Participants: {', '.join([self.name, *names])}")
232
+ elif command == "/clear":
233
+ if terminal.interactive:
234
+ sys.stdout.write("\x1b[2J\x1b[H")
235
+ sys.stdout.flush()
236
+ elif command.startswith("/"):
237
+ terminal.say("Unknown command. Use /help.")
238
+ elif len(text) > MAX_TEXT:
239
+ self.draft = text
240
+ terminal.say("Message limit: 2000 characters.")
241
+ else:
242
+ self.draft = text
243
+ if await self.send_message(text):
244
+ self.draft = ""
245
+ if not terminal.interactive:
246
+ terminal.say(f"{self.name}> {text}")
247
+ finally:
248
+ if not terminal.interactive:
249
+ stopped.set()
250
+ self.finished.set()
251
+
252
+ async def run(self, url, create=False, code_to_show=None, ca=None):
253
+ kwargs = {}
254
+ if ca:
255
+ if not url.startswith("wss://"):
256
+ raise ValueError("--ca requires wss://.")
257
+ kwargs["ssl"] = ssl.create_default_context(cafile=ca)
258
+ input_task = None
259
+ stop_task = asyncio.create_task(self.finished.wait())
260
+ receiver = None
261
+ deadline = None
262
+ try:
263
+ while not self.finished.is_set():
264
+ if deadline is not None and time.monotonic() >= deadline:
265
+ raise SessionError("Reconnect window expired. Start a new chat.")
266
+ timeout = 10 if deadline is None else min(10, max(0.1, deadline - time.monotonic()))
267
+ try:
268
+ async with connect(url, compression=None, max_size=MAX_FRAME, max_queue=16,
269
+ open_timeout=timeout, ping_interval=5, ping_timeout=5,
270
+ close_timeout=1, proxy=None, **kwargs) as ws:
271
+ action = "resume" if self.token else "create" if create else "join"
272
+ hello = {"type": action, "room": self.keys.room, "session": self.session}
273
+ if self.token:
274
+ hello["token"] = self.token
275
+ await ws.send(dumps(hello))
276
+ remaining = 10 if deadline is None else min(10, max(0.1, deadline - time.monotonic()))
277
+ reply = parse(await asyncio.wait_for(ws.recv(), remaining))
278
+ if reply.get("type") == "error":
279
+ if not isinstance(reply.get("code"), str):
280
+ raise SessionError("Invalid server response.")
281
+ messages = {
282
+ "invalid_room": "Invalid or expired code.",
283
+ "invalid_resume": "Session expired. Start a new chat.",
284
+ "full": "This chat already has two participants.",
285
+ "exists": "Create a new chat with a new code.",
286
+ "busy": "Server is busy. Try again later.",
287
+ }
288
+ if reply.get("code") == "busy" and self.token:
289
+ raise ConnectionError("Server busy.")
290
+ raise SessionError(messages.get(reply.get("code"), "Unable to join chat."))
291
+ if (reply.get("type") != "ready" or reply.get("version") != 2
292
+ or not isinstance(reply.get("token"), str)
293
+ or not re.fullmatch(r"[A-Za-z0-9_-]{43}", reply["token"])):
294
+ raise SessionError("Incompatible server.")
295
+ reconnected = self.token is not None
296
+ self.token = reply["token"]
297
+ self.ws = ws
298
+ self.online = {self.session}
299
+ self.peer_ready = False
300
+ deadline = None
301
+ if reconnected:
302
+ self.terminal.say("Reconnected.")
303
+ elif code_to_show:
304
+ self.terminal.say(f"Code: {code_to_show}")
305
+ await ws.send(dumps(self.packet("hello")))
306
+ if input_task is None:
307
+ input_task = asyncio.create_task(self.send_input())
308
+ receiver = asyncio.create_task(self.receive(ws))
309
+ try:
310
+ done, _ = await asyncio.wait({receiver, input_task, stop_task},
311
+ return_when=asyncio.FIRST_COMPLETED)
312
+ except asyncio.CancelledError:
313
+ with contextlib.suppress(ConnectionClosed, OSError):
314
+ await ws.send(dumps({"type": "leave"}))
315
+ raise
316
+ if input_task in done:
317
+ input_task.result()
318
+ if self.finished.is_set():
319
+ with contextlib.suppress(ConnectionClosed, OSError):
320
+ await ws.send(dumps({"type": "leave"}))
321
+ return
322
+ receiver.result()
323
+ except (ConnectionClosed, ConnectionError, OSError, TimeoutError, asyncio.TimeoutError, InvalidHandshake) as error:
324
+ if not self.token:
325
+ raise
326
+ if isinstance(error, ConnectionClosed) and error.rcvd and error.rcvd.code in {1008, 4001}:
327
+ raise SessionError("Session ended. Start a new chat.") from error
328
+ if isinstance(error, ssl.SSLCertVerificationError):
329
+ raise
330
+ if deadline is None:
331
+ deadline = time.monotonic() + self.reconnect_seconds
332
+ self.terminal.say("Disconnected. Reconnecting...")
333
+ finally:
334
+ self.ws = None
335
+ self.peer_ready = False
336
+ self.online = {self.session}
337
+ self.fail_pending()
338
+ for peer in self.peers.values():
339
+ peer["online"] = False
340
+ if receiver:
341
+ receiver.cancel()
342
+ await asyncio.gather(receiver, return_exceptions=True)
343
+ receiver = None
344
+ if deadline is not None:
345
+ remaining = deadline - time.monotonic()
346
+ if remaining <= 0:
347
+ raise SessionError("Reconnect window expired. Start a new chat.")
348
+ with contextlib.suppress(TimeoutError, asyncio.TimeoutError):
349
+ await asyncio.wait_for(self.finished.wait(), min(1, remaining))
350
+ finally:
351
+ self.finished.set()
352
+ self.fail_pending()
353
+ for task in (input_task, receiver, stop_task):
354
+ if task:
355
+ task.cancel()
356
+ await asyncio.gather(*(task for task in (input_task, receiver, stop_task) if task),
357
+ return_exceptions=True)
358
+
359
+
360
+ def main():
361
+ parser = argparse.ArgumentParser(prog="dark-chat", description="Two-person encrypted terminal chat.")
362
+ parser.add_argument("--server", default=os.environ.get("DARK_CHAT_SERVER", DEFAULT_SERVER),
363
+ help="Server address (defaults to the hosted relay)")
364
+ parser.add_argument("--name", help="Name (1-24 characters)")
365
+ mode = parser.add_mutually_exclusive_group()
366
+ mode.add_argument("--new", action="store_true", help="Create a chat and get an invite code")
367
+ mode.add_argument("--chat", action="store_true", help="Join a chat using its invite code")
368
+ parser.add_argument("--allow-insecure", action="store_true", help="Allow ws:// on a local network")
369
+ parser.add_argument("--ca", help="Private TLS CA certificate")
370
+ parser.add_argument("--version", action="version", version=__version__)
371
+ args = parser.parse_args()
372
+ terminal = Terminal()
373
+ try:
374
+ url = server_url(args.server, args.allow_insecure)
375
+ name = safe_text(args.name if args.name is not None else terminal.ask("Name: ")).strip()
376
+ if not 1 <= len(name) <= 24:
377
+ raise ValueError("Name must be 1-24 characters.")
378
+ code = os.environ.pop("DARK_CHAT_CODE", "").strip()
379
+ create = args.new
380
+ if not create and not args.chat and not code:
381
+ choice = terminal.ask("Create or join? [c/j]: ").lower()
382
+ if choice not in {"c", "j", ""}:
383
+ raise ValueError("Enter c to create or j to join.")
384
+ create = choice == "c"
385
+ if create:
386
+ code = new_code()
387
+ elif not code:
388
+ code = terminal.ask("Code: ", secret=True)
389
+ keys = room_keys(code)
390
+ stdout_context = patch_stdout() if terminal.interactive else contextlib.nullcontext()
391
+ with stdout_context:
392
+ asyncio.run(Chat(terminal, keys, name).run(url, create, code if create else None, args.ca))
393
+ terminal.say("Closed.")
394
+ except (KeyboardInterrupt, EOFError):
395
+ terminal.say("Closed.")
396
+ except SessionError as error:
397
+ terminal.say(str(error))
398
+ raise SystemExit(1) from None
399
+ except (ValueError, OSError, ConnectionClosed, ConnectionError, TimeoutError, asyncio.TimeoutError, InvalidHandshake) as error:
400
+ detail = str(error) if isinstance(error, ValueError) else type(error).__name__
401
+ terminal.say(f"Error: {detail}")
402
+ raise SystemExit(1) from None
403
+
404
+
405
+ if __name__ == "__main__":
406
+ main()
@@ -0,0 +1,105 @@
1
+ """The encryption format is shared with the original Node.js client."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import secrets
8
+ import unicodedata
9
+ from dataclasses import dataclass
10
+
11
+ from cryptography.hazmat.primitives import hashes
12
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
13
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
14
+
15
+ MAX_FRAME = 20_000
16
+ MAX_TEXT = 2_000
17
+ SESSION = re.compile(r"[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}\Z")
18
+
19
+
20
+ def encode(value: bytes) -> str:
21
+ return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
22
+
23
+
24
+ def decode(value: str) -> bytes:
25
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
26
+
27
+
28
+ def new_code() -> str:
29
+ return encode(secrets.token_bytes(32))
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class RoomKeys:
34
+ room: str
35
+ key: bytes
36
+
37
+
38
+ def room_keys(code: str) -> RoomKeys:
39
+ if not re.fullmatch(r"[A-Za-z0-9_-]{43}", code):
40
+ raise ValueError("Enter the 43-character code from the chat creator.")
41
+ secret = decode(code)
42
+ if encode(secret) != code:
43
+ raise ValueError("Invalid code.")
44
+ room = hashlib.sha256(b"dark-terminal-room-v1:" + secret).hexdigest()
45
+ key = HKDF(algorithm=hashes.SHA256(), length=32, salt=b"dark-terminal-v1",
46
+ info=b"message-encryption").derive(secret)
47
+ return RoomKeys(room, key)
48
+
49
+
50
+ def dumps(value: dict) -> str:
51
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
52
+
53
+
54
+ def parse(raw: str | bytes) -> dict:
55
+ if not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_FRAME:
56
+ raise ValueError("Invalid packet.")
57
+ try:
58
+ value = json.loads(raw)
59
+ except (RecursionError, json.JSONDecodeError) as error:
60
+ raise ValueError("Invalid JSON.") from error
61
+ if not isinstance(value, dict):
62
+ raise ValueError("Packet must be an object.")
63
+ return value
64
+
65
+
66
+ def valid_envelope(value: dict) -> bool:
67
+ return isinstance(value, dict) and value.get("type") == "message" and all(
68
+ isinstance(value.get(field), str) and re.fullmatch(pattern, value[field])
69
+ for field, pattern in (
70
+ ("iv", r"[A-Za-z0-9_-]{16}"),
71
+ ("tag", r"[A-Za-z0-9_-]{22}"),
72
+ ("body", r"[A-Za-z0-9_-]{1,16000}"),
73
+ )
74
+ )
75
+
76
+
77
+ def encrypt(keys: RoomKeys, message: dict) -> dict:
78
+ iv = secrets.token_bytes(12)
79
+ encrypted = AESGCM(keys.key).encrypt(iv, dumps(message).encode("utf-8"), keys.room.encode())
80
+ return {"type": "message", "iv": encode(iv), "body": encode(encrypted[:-16]),
81
+ "tag": encode(encrypted[-16:])}
82
+
83
+
84
+ def decrypt(keys: RoomKeys, envelope: dict) -> dict:
85
+ if not valid_envelope(envelope):
86
+ raise ValueError("Invalid encrypted packet.")
87
+ raw = AESGCM(keys.key).decrypt(decode(envelope["iv"]),
88
+ decode(envelope["body"]) + decode(envelope["tag"]), keys.room.encode())
89
+ return parse(raw.decode("utf-8"))
90
+
91
+
92
+ def safe_text(value: str) -> str:
93
+ return "".join(char for char in value if unicodedata.category(char) not in {"Cc", "Cf", "Zl", "Zp", "Cs"})
94
+
95
+
96
+ def valid_message(value: dict) -> bool:
97
+ return (
98
+ isinstance(value, dict)
99
+ and isinstance(value.get("kind"), str) and value["kind"] in {"hello", "chat", "ack"}
100
+ and isinstance(value.get("name"), str) and 1 <= len(value["name"]) <= 24
101
+ and isinstance(value.get("session"), str) and SESSION.fullmatch(value["session"]) is not None
102
+ and type(value.get("seq")) is int and 1 <= value["seq"] <= 2**53 - 1
103
+ and isinstance(value.get("text"), str) and len(value["text"]) <= MAX_TEXT
104
+ and (value["kind"] != "ack" or type(value.get("ack")) is int and 1 <= value["ack"] <= 2**53 - 1)
105
+ )
@@ -0,0 +1,247 @@
1
+ """Two-person WebSocket relay. Only room/session metadata lives in memory."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import os
7
+ import re
8
+ import secrets
9
+ import signal
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from http import HTTPStatus
13
+ from urllib.parse import urlsplit
14
+
15
+ from websockets.asyncio.server import serve
16
+ from websockets.exceptions import ConnectionClosed
17
+
18
+ from . import __version__
19
+ from .protocol import MAX_FRAME, SESSION, dumps, new_code, parse, valid_envelope
20
+
21
+ RECONNECT_SECONDS = 60
22
+
23
+
24
+ @dataclass
25
+ class Participant:
26
+ socket: object
27
+ token: str = field(default_factory=new_code)
28
+ expiry: object = None
29
+
30
+
31
+ @dataclass
32
+ class Room:
33
+ participants: dict = field(default_factory=dict)
34
+
35
+
36
+ class Relay:
37
+ def __init__(self, max_clients=128, reconnect_seconds=RECONNECT_SECONDS):
38
+ self.rooms = {}
39
+ self.clients = set()
40
+ self.max_clients = max_clients
41
+ self.reconnect_seconds = reconnect_seconds
42
+ self.tasks = set()
43
+
44
+ @staticmethod
45
+ def http_request(connection, request):
46
+ path = urlsplit(request.path).path.rstrip("/") or "/"
47
+ if path == "/health":
48
+ response = connection.respond(HTTPStatus.OK, '{"status":"ok"}\n')
49
+ response.headers["Content-Type"] = "application/json"
50
+ response.headers["Cache-Control"] = "no-store"
51
+ return response
52
+ if path not in {"/", "/ws"}:
53
+ return connection.respond(HTTPStatus.NOT_FOUND, "Not found\n")
54
+ if request.headers.get("Upgrade", "").lower() != "websocket":
55
+ return connection.respond(HTTPStatus.OK, "Chat server is running.\n")
56
+ return None
57
+
58
+ async def send(self, ws, frame):
59
+ try:
60
+ await asyncio.wait_for(ws.send(dumps(frame)), timeout=3)
61
+ return True
62
+ except (ConnectionClosed, TimeoutError, asyncio.TimeoutError):
63
+ await ws.close(code=1013, reason="Connection lost")
64
+ return False
65
+
66
+ async def reject(self, ws, code, message):
67
+ await self.send(ws, {"type": "error", "code": code, "message": message})
68
+ await ws.close(code=1013 if code == "busy" else 1008, reason=message)
69
+
70
+ async def presence(self, room_id):
71
+ room = self.rooms.get(room_id)
72
+ if room:
73
+ online = {sid: peer.socket for sid, peer in room.participants.items() if peer.socket is not None}
74
+ frame = {"type": "presence", "sessions": list(online), "reserved": list(room.participants)}
75
+ await asyncio.gather(*(self.send(ws, frame) for ws in online.values()))
76
+
77
+ def expire(self, room_id, session, participant):
78
+ room = self.rooms.get(room_id)
79
+ if not room or room.participants.get(session) is not participant or participant.socket is not None:
80
+ return
81
+ room.participants.pop(session)
82
+ if not room.participants:
83
+ self.rooms.pop(room_id)
84
+ else:
85
+ task = asyncio.create_task(self.presence(room_id))
86
+ self.tasks.add(task)
87
+ task.add_done_callback(self.tasks.discard)
88
+
89
+ def close(self):
90
+ for room in self.rooms.values():
91
+ for peer in room.participants.values():
92
+ if peer.expiry:
93
+ peer.expiry.cancel()
94
+ for task in self.tasks:
95
+ task.cancel()
96
+ self.rooms.clear()
97
+
98
+ async def handler(self, ws):
99
+ if len(self.clients) >= self.max_clients:
100
+ await self.reject(ws, "busy", "Server is busy. Try again later.")
101
+ return
102
+ self.clients.add(ws)
103
+ room_id = session = participant = None
104
+ left = False
105
+ try:
106
+ frame = parse(await asyncio.wait_for(ws.recv(), timeout=10))
107
+ room_id, session, action = frame.get("room"), frame.get("session"), frame.get("type")
108
+ if (not isinstance(action, str) or action not in {"create", "join", "resume"}
109
+ or not isinstance(room_id, str) or not re.fullmatch(r"[a-f0-9]{64}", room_id)
110
+ or not isinstance(session, str) or not SESSION.fullmatch(session)):
111
+ raise ValueError("Invalid handshake")
112
+ room = self.rooms.get(room_id)
113
+ if action == "create":
114
+ if room:
115
+ await self.reject(ws, "exists", "Create a new chat with a new code.")
116
+ return
117
+ if len(self.rooms) >= self.max_clients:
118
+ await self.reject(ws, "busy", "Server is busy. Try again later.")
119
+ return
120
+ room = Room()
121
+ self.rooms[room_id] = room
122
+ elif not room:
123
+ await self.reject(ws, "invalid_room", "Invalid or expired code.")
124
+ return
125
+
126
+ if action == "resume":
127
+ participant = room.participants.get(session)
128
+ token = frame.get("token")
129
+ if (not participant or not isinstance(token, str)
130
+ or not re.fullmatch(r"[A-Za-z0-9_-]{43}", token)
131
+ or not secrets.compare_digest(token, participant.token)):
132
+ participant = None
133
+ await self.reject(ws, "invalid_resume", "Session expired. Create a new chat.")
134
+ return
135
+ old_socket = participant.socket
136
+ participant.socket = ws
137
+ if participant.expiry:
138
+ participant.expiry.cancel()
139
+ participant.expiry = None
140
+ if old_socket is not None and old_socket is not ws:
141
+ await old_socket.close(code=4001, reason="Connection replaced")
142
+ else:
143
+ if len(room.participants) >= 2:
144
+ await self.reject(ws, "full", "This chat already has two participants.")
145
+ return
146
+ if session in room.participants:
147
+ await self.reject(ws, "invalid_resume", "Session already exists.")
148
+ return
149
+ participant = Participant(ws)
150
+ room.participants[session] = participant
151
+
152
+ await self.send(ws, {"type": "ready", "version": 2, "token": participant.token})
153
+ await self.presence(room_id)
154
+ window, budget = time.monotonic(), 40
155
+ async for raw in ws:
156
+ if participant.socket is not ws:
157
+ break
158
+ if time.monotonic() - window >= 10:
159
+ window, budget = time.monotonic(), 40
160
+ budget -= 1
161
+ if budget < 0:
162
+ left = True
163
+ await ws.close(code=1008, reason="Rate limit exceeded")
164
+ break
165
+ frame = parse(raw)
166
+ if frame.get("type") == "leave":
167
+ left = True
168
+ await ws.close(code=1000, reason="Left chat")
169
+ break
170
+ message_id, receipt = frame.get("id"), frame.get("receipt", False)
171
+ if (not valid_envelope(frame) or type(message_id) is not int
172
+ or not 1 <= message_id <= 2**53 - 1 or type(receipt) is not bool):
173
+ left = True
174
+ raise ValueError("Invalid message")
175
+ room = self.rooms.get(room_id)
176
+ target = next((peer.socket for sid, peer in room.participants.items()
177
+ if sid != session and peer.socket is not None), None) if room else None
178
+ outgoing = {key: frame[key] for key in ("type", "iv", "body", "tag", "id")}
179
+ outgoing["sender"] = session
180
+ delivered = target is not None and await self.send(target, outgoing)
181
+ if receipt and not delivered:
182
+ await self.send(ws, {"type": "not_delivered", "id": message_id})
183
+ except ConnectionClosed as error:
184
+ if error.rcvd and error.rcvd.code == 1008:
185
+ left = True
186
+ except (ValueError, TimeoutError, asyncio.TimeoutError, UnicodeError):
187
+ left = True
188
+ await ws.close(code=1008, reason="Invalid protocol")
189
+ finally:
190
+ self.clients.discard(ws)
191
+ room = self.rooms.get(room_id) if isinstance(room_id, str) else None
192
+ if room and participant and participant.socket is ws:
193
+ participant.socket = None
194
+ if left:
195
+ room.participants.pop(session, None)
196
+ if not room.participants:
197
+ self.rooms.pop(room_id, None)
198
+ else:
199
+ participant.expiry = asyncio.get_running_loop().call_later(
200
+ self.reconnect_seconds, self.expire, room_id, session, participant)
201
+ await self.presence(room_id)
202
+
203
+ def serve(self, host, port, **kwargs):
204
+ return serve(self.handler, host, port, process_request=self.http_request,
205
+ origins=[None], compression=None, max_size=MAX_FRAME,
206
+ max_queue=16, write_limit=32768, ping_interval=5,
207
+ ping_timeout=5, close_timeout=3, server_header=None, **kwargs)
208
+
209
+
210
+ async def run(args):
211
+ relay = Relay(args.max_clients)
212
+ stop = asyncio.Event()
213
+ loop = asyncio.get_running_loop()
214
+ for sig in (signal.SIGINT, signal.SIGTERM):
215
+ try:
216
+ loop.add_signal_handler(sig, stop.set)
217
+ except NotImplementedError:
218
+ pass
219
+ try:
220
+ async with relay.serve(args.host, args.port) as server:
221
+ address = server.sockets[0].getsockname()
222
+ print(f"Listening on {address[0]}:{address[1]}", flush=True)
223
+ await stop.wait()
224
+ finally:
225
+ relay.close()
226
+
227
+
228
+ def main():
229
+ parser = argparse.ArgumentParser(description="Two-person encrypted chat server.")
230
+ parser.add_argument("--host", default=os.environ.get("IP", "127.0.0.1"))
231
+ parser.add_argument("--port", type=int, default=os.environ.get("PORT", "8080"))
232
+ parser.add_argument("--max-clients", type=int, default=128)
233
+ parser.add_argument("--version", action="version", version=__version__)
234
+ args = parser.parse_args()
235
+ if not 1 <= args.port <= 65535 or not 1 <= args.max_clients <= 1024:
236
+ parser.error("Port must be 1–65535; max-clients must be 1–1024.")
237
+ logging.basicConfig(level=logging.WARNING)
238
+ try:
239
+ asyncio.run(run(args))
240
+ except KeyboardInterrupt:
241
+ pass
242
+ except OSError as error:
243
+ parser.exit(1, f"Server error: {error.strerror or type(error).__name__}\n")
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()