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.
- dark_chat-2.1.0.dist-info/METADATA +150 -0
- dark_chat-2.1.0.dist-info/RECORD +10 -0
- dark_chat-2.1.0.dist-info/WHEEL +5 -0
- dark_chat-2.1.0.dist-info/entry_points.txt +3 -0
- dark_chat-2.1.0.dist-info/top_level.txt +1 -0
- dark_terminal_chat/__init__.py +3 -0
- dark_terminal_chat/__main__.py +3 -0
- dark_terminal_chat/client.py +406 -0
- dark_terminal_chat/protocol.py +105 -0
- dark_terminal_chat/server.py +247 -0
|
@@ -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 @@
|
|
|
1
|
+
dark_terminal_chat
|
|
@@ -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()
|