From b0ff6120232f800d27e1192b46a787da53409457 Mon Sep 17 00:00:00 2001 From: mirai Date: Wed, 10 Sep 2025 19:58:59 +0300 Subject: [PATCH] Password update --- README.MD | 65 ++++----- ROADMAP.md | 47 ++++++ cmd_chat/__init__.py | 76 ++++------ cmd_chat/client/client.py | 191 ++++++++++++++++--------- cmd_chat/client/core/abs/abs_crypto.py | 3 +- cmd_chat/client/core/crypto.py | 14 +- cmd_chat/server/server.py | 99 ++++++++----- 7 files changed, 293 insertions(+), 202 deletions(-) create mode 100644 ROADMAP.md diff --git a/README.MD b/README.MD index a106ccf..d689d8e 100644 --- a/README.MD +++ b/README.MD @@ -3,65 +3,57 @@ **CMD CHAT** is a new milestone in console communication. A fully anonymous chat between two clients, impossible to intercept or hand over. All data exists only in RAM and is wiped after the session ends. -No logs, no traces, no compromise. +No logs, no traces, no compromise. --- ## ๐Ÿ”’ Key Features -- Full anonymity -- End-to-End encryption (RSA + symmetric key) -- Data stored only in memory (RAM), deleted on exit -- No logging, no persistence on disk -- Easy to run via Python or CLI +- Full anonymity +- End-to-End encryption (RSA + symmetric key) +- Data stored only in memory (RAM), deleted on exit +- No logging, no persistence on disk +- Easy to run via Python or CLI --- ## โš™๏ธ How It Works -1. The client generates an RSA key pair. -2. The server creates a symmetric key. -3. The client sends its public key to the server. -4. The server encrypts the symmetric key and sends it back. -5. The client decrypts and confirms the key. -6. From that point, all communication is done via symmetric encryption. +1. The client generates an RSA key pair. +2. The server creates a symmetric key. +3. The client sends its public key to the server. +4. The server encrypts the symmetric key and sends it back. +5. The client decrypts and confirms the key. +6. From that point, all communication is done via symmetric encryption. -Everything happens in memory only. Nothing is written to disk. +Everything happens in memory only. Nothing is written to disk. --- ## ๐Ÿš€ Installation & Run -### Option 1: Python +### Python -Install with pip: -```bash -pip install secured_console_chat -```` +1. Clone the repository: + `git clone https://github.com/emilycodestar/cmd-chat.git` + `cd cmd-chat` -Run directly from Python: +2. Create a virtual environment and install dependencies: -```python -import asyncio -import cmd_chat + Linux / macOS: + `python -m venv venv && source venv/bin/activate && pip install -r requirements.txt` -if __name__ == '__main__': - asyncio.run(cmd_chat.run()) -``` + Windows (PowerShell): + `python -m venv venv ; .\venv\Scripts\activate ; pip install -r requirements.txt` -### Option 2: CLI +3. Start the server (set a password for client connections): + `python cmd_chat.py serve 0.0.0.0 1000 --password YOUR_PASSWORD` -Start the server: +4. Connect a client: + `python cmd_chat.py connect SERVER_IP 1000 USERNAME YOUR_PASSWORD` -```bash -cmd_chat serve localhost 5000 -``` - -Connect to the server: - -```bash -cmd_chat connect localhost 5000 tyler -``` + Example (local run): + `python cmd_chat.py connect localhost 1000 tyler YOUR_PASSWORD` --- @@ -70,4 +62,3 @@ cmd_chat connect localhost 5000 tyler Hereโ€™s how it looks in action: ![Example](example.gif) - diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..c7b38f3 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,47 @@ +## ๐Ÿ—บ๏ธ Roadmap + +### 1. Core stability + +- [ ] Switch from `ast.literal_eval` to `json` for messages. +- [ ] Add proper reconnect with heartbeat (ping/pong, timeouts). +- [ ] Clean error handling (no leaking stack traces to clients). +- [ ] Limit message size and frequency (basic anti-spam). + +### 2. Security improvements + +- [ ] Per-client symmetric keys instead of one global key. +- [ ] Upgrade RSA 512 โ†’ 2048 (or curve25519 ECDH + HKDF). +- [ ] Replace shared password with invite tokens or session-based bearer tokens. +- [ ] Force WSS (TLS) for production. + +### 3. Chat features + +- [ ] Multiple rooms (room_id support). +- [ ] Commands (`/nick`, `/clear`, `/help`, `/quit`). +- [ ] Message timestamps + sequence numbers. +- [ ] Delta updates (only send new messages instead of full history). + +### 4. UX / Client + +- [ ] Local encrypted history (optional). +- [ ] Customizable renderers (rich, minimal, json mode). +- [ ] Quiet reconnection status indicator. +- [ ] Configurable message buffer length. + +### 5. File & media + +- [ ] File transfer via encrypted chunks. +- [ ] Inline images (optional, in rich renderer). + +### 6. Deployment & Ops + +- [ ] Dockerfile + docker-compose (server + client). +- [ ] Add uvloop + multiple Sanic workers. +- [ ] Graceful shutdown & restart. +- [ ] Systemd service unit for server. + +### 7. Privacy & audit + +- [ ] Disable sensitive logs (no passwords/tokens in logs). +- [ ] Minimal server metrics: connected users, msg/sec. +- [ ] Configurable retention (in-memory only vs file-based). diff --git a/cmd_chat/__init__.py b/cmd_chat/__init__.py index 6c39165..abc7a9a 100644 --- a/cmd_chat/__init__.py +++ b/cmd_chat/__init__.py @@ -1,64 +1,38 @@ -import asyncio +import asyncio import argparse - from cmd_chat.server.server import run_server from cmd_chat.client.client import Client +def run_http_server(ip: str, port: int, password: str | None) -> None: + run_server(ip, int(port), False, password) -def run_http_server( - ip: str, - port: int -) -> None: - run_server(ip, port, False) - - -async def run_client( - username: str, - server: str, - port: int -) -> None: - Client( - server = server, - port = port, - username = username - ).run() - +async def run_client(username: str, server: str, port: int, password: str | None) -> None: + Client(server=server, port=port, username=username, password=password).run() async def run() -> None: - parser = argparse.ArgumentParser( - description='Command-line chat application' - ) - parser.add_argument( - 'command', - choices=['serve', 'connect'], - help='Command to execute' - ) - parser.add_argument( - 'ip_address', - help='IP address to serve or connect' - ) - parser.add_argument( - 'port', - help='PORT of server' - ) - parser.add_argument( - 'username', - nargs='?', - default='', - help='Username for connection (required for connect command)' - ) - args = parser.parse_args() - if args.command == 'serve': - run_http_server(args.ip_address, int(args.port)) - elif args.command == 'connect': - if not args.username: - parser.error("Username is required for 'connect' command") - await run_client(args.username, args.ip_address, int(args.port)) + parser = argparse.ArgumentParser(description='Command-line chat application') + subparsers = parser.add_subparsers(dest='command', required=True) + serve_p = subparsers.add_parser('serve', help='Run server') + serve_p.add_argument('ip_address') + serve_p.add_argument('port') + serve_p.add_argument('--password', '-p', required=True, help='Admin password required for clients') + + connect_p = subparsers.add_parser('connect', help='Connect to server') + connect_p.add_argument('ip_address') + connect_p.add_argument('port') + connect_p.add_argument('username') + connect_p.add_argument('password', help='Password to auth on server') + + args = parser.parse_args() + + if args.command == 'serve': + run_http_server(args.ip_address, args.port, args.password) + elif args.command == 'connect': + await run_client(args.username, args.ip_address, int(args.port), args.password) def main(): asyncio.run(run()) - if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/cmd_chat/client/client.py b/cmd_chat/client/client.py index 52b21ab..c1dd387 100644 --- a/cmd_chat/client/client.py +++ b/cmd_chat/client/client.py @@ -1,8 +1,9 @@ -import ast +import ast import time import threading +from typing import Optional -from websocket import create_connection +from websocket import create_connection, WebSocketConnectionClosedException from cmd_chat.client.core.crypto import RSAService from cmd_chat.client.core.default_renderer import DefaultClientRenderer @@ -14,107 +15,157 @@ from cmd_chat.client.config import RENDER_TIME class Client(RSAService, RichClientRenderer): def __init__( - self, - server: str, - port: int, - username: str + self, + server: str, + port: int, + username: str, + password: Optional[str] = None ): super().__init__() - # Server info self.server = server self.port = port self.username = username - # Urls + self.password = password or "" self.base_url = f"http://{self.server}:{self.port}" - self.talk_url = f"{self.base_url}/talk" - self.info_url = f"{self.base_url}/update" - self.key_url = f"{self.base_url}/get_key" self.ws_url = f"ws://{self.server}:{self.port}" self.close_response = str({ "action": "close", "username": self.username }) - # Threads - self.__stop_threads = False + self.__stop_threads = False + + def _ws_full(self, path: str) -> str: + if self.password: + return f"{self.ws_url}{path}?password={self.password}" + return f"{self.ws_url}{path}" + + def _connect_ws(self, path: str, retries: int = 5, backoff: float = 0.5): + last_exc = None + for attempt in range(retries): + try: + return create_connection(self._ws_full(path)) + except Exception as exc: + last_exc = exc + time.sleep(backoff * (2 ** attempt)) + print(f"Can't connect to {path}: {last_exc}") + raise last_exc def send_info(self): - """ sending message to websocket - """ - ws = create_connection(f"{self.ws_url}/talk") - while not self.__stop_threads: + ws = self._connect_ws("/talk") + try: + while not self.__stop_threads: + try: + user_input = input("You're message: ") + if user_input == "q": + self.__stop_threads = True + try: + if ws: + ws.send(self.close_response) + ws.close() + except Exception: + pass + break + message = f'{self.username}: {user_input}' + socket_message = str({ + "text": self._encrypt(message), + "username": self.username + }) + ws.send(socket_message) + except (WebSocketConnectionClosedException, ConnectionResetError, ConnectionAbortedError, OSError): + try: + if ws: + try: + ws.close() + except Exception: + pass + ws = self._connect_ws("/talk") + continue + except Exception: + print("Can't establish channel") + self.__stop_threads = True + break + except KeyboardInterrupt: + self.__stop_threads = True + try: + ws.send(self.close_response) + ws.close() + except Exception: + pass + break + finally: try: - user_input = input("You're message: ") - if user_input == "q": - self.__stop_threads = True - message = f'{self.username}: {user_input}' - socket_message = str({ - "text": self._encrypt(message), - "username": self.username - }) - ws.send(payload=socket_message.encode()) - except KeyboardInterrupt: - ws.send(self.close_response) ws.close() - self.__stop_threads = True - except Exception as exc: - ws.send(self.close_response) - ws.close() - print("Something went wrong! ", exc) - self.__stop_threads = True - raise exc + except Exception: + pass def update_info(self): - """ connecting to websocket, - wating for updates, - updating every RENDER_TIME seconds - """ - ws = create_connection(f"{self.ws_url}/update") + ws = self._connect_ws("/update") last_try = None - while not self.__stop_threads: + try: + while not self.__stop_threads: + try: + time.sleep(RENDER_TIME) + raw = ws.recv() + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + response = ast.literal_eval(raw) + if last_try == response: + continue + last_try = response + self.clear_console() + if len(last_try["messages"]) > 0: + self.print_chat(response=last_try) + except (WebSocketConnectionClosedException, ConnectionResetError, ConnectionAbortedError, OSError): + try: + if ws: + try: + ws.close() + except Exception: + pass + ws = self._connect_ws("/update") + continue + except Exception: + print("Connection lost: can't establish update channel") + self.__stop_threads = True + break + except KeyboardInterrupt: + self.__stop_threads = True + try: + ws.send(self.close_response) + ws.close() + except Exception: + pass + break + finally: try: - time.sleep(RENDER_TIME) - response = ast.literal_eval(ws.recv().decode('utf-8')) - if last_try == response: - continue - last_try = response - self.clear_console() - if len(last_try["messages"]) > 0: - self.print_chat(response = last_try) - except KeyboardInterrupt: - ws.send(self.close_response) ws.close() - self.__stop_threads = True - except ConnectionAbortedError: - # Reconnect if somehow client was disconnected - ws = create_connection(f"{self.ws_url}/update") - continue - except Exception as exc: - ws.send(self.close_response) - ws.close() - print("Something went wrong! ", exc) - self.__stop_threads = True - raise exc - + except Exception: + pass + def _validate_keys(self) -> None: - self._request_key(self.key_url, self.username) + self._request_key( + url=f"{self.base_url}/get_key", + username=self.username, + password=self.password + ) self._remove_keys() def run(self): - # Running two threads, - # One for sending info - # Second one for updating info self._validate_keys() threads = [ - threading.Thread(target=self.send_info), - threading.Thread(target=self.update_info) + threading.Thread(target=self.send_info, daemon=True), + threading.Thread(target=self.update_info, daemon=True) ] for th in threads: th.start() + for th in threads: + th.join() if __name__ == '__main__': Client( server=input("server ip:\n"), port=int(input("server port: \n")), - username=input("username:\n").replace(" ", "").lower() + username=input("username:\n").replace(" ", "").lower(), + password=input("password:\n") ).run() diff --git a/cmd_chat/client/core/abs/abs_crypto.py b/cmd_chat/client/core/abs/abs_crypto.py index 8de2a0d..f82f4ba 100644 --- a/cmd_chat/client/core/abs/abs_crypto.py +++ b/cmd_chat/client/core/abs/abs_crypto.py @@ -1,6 +1,5 @@ from abc import ABC, abstractmethod - class CryptoService(ABC): @abstractmethod @@ -12,7 +11,7 @@ class CryptoService(ABC): raise NotImplementedError("Need to implement decrypt method") @abstractmethod - def _request_key(self, url: str, username: str): + def _request_key(self, url: str, username: str, password: str | None = None): raise NotImplementedError("Need to implement request key method") @abstractmethod diff --git a/cmd_chat/client/core/crypto.py b/cmd_chat/client/core/crypto.py index 0b5e317..90cec67 100644 --- a/cmd_chat/client/core/crypto.py +++ b/cmd_chat/client/core/crypto.py @@ -19,17 +19,20 @@ class RSAService(CryptoService): self._generate_keys() def _encrypt(self, message: str) -> str: - return self.fernet.encrypt(message.encode()) + return self.fernet.encrypt(message.encode()).decode("utf-8") def _decrypt(self, message: str) -> str: return self.fernet.decrypt(message.encode()).decode("utf-8") - def _request_key(self, url: str, username: str): + def _request_key(self, url: str, username: str, password: str | None = None): data = { "pubkey": self._open_generated_file(self.public_key_name), "username": username } - r = requests.get(url, data=data, stream=True) + if password: + data["password"] = password + r = requests.post(url, files={"pubkey": ("public.pem", data["pubkey"])}, data={"username": username, "password": password or ""}, stream=True) + r.raise_for_status() message = r.raw.read(999) self.symmetric_key = rsa.decrypt(message, self.private_key) self.fernet = Fernet(self.symmetric_key) @@ -63,4 +66,7 @@ class RSAService(CryptoService): def _remove_keys(self): for key in self.keys_path: - os.remove(key) + try: + os.remove(key) + except FileNotFoundError: + pass diff --git a/cmd_chat/server/server.py b/cmd_chat/server/server.py index 3636262..2d05239 100644 --- a/cmd_chat/server/server.py +++ b/cmd_chat/server/server.py @@ -1,15 +1,10 @@ -import asyncio - +import asyncio import rsa from cryptography.fernet import Fernet - from functools import partial - from sanic.worker.loader import AppLoader - from sanic.response import HTTPResponse from sanic import Sanic, Request, response, Websocket - from cmd_chat.server.models import Message from cmd_chat.server.services import ( _get_bytes_and_serialize, @@ -18,70 +13,98 @@ from cmd_chat.server.services import ( _generate_update_payload ) - app = Sanic("app") app.config.OAS = False - -# Message structure is: -# [username: message, ...] MESSAGES_MEMORY_DB: list[Message] = [] - - -# Users structure is -# {Ip, Username: Public key} USERS: dict[str, str] = {} PUBLIC_KEY = Fernet.generate_key() -def attach_endpoints(app: Sanic): +def _check_password(request: Request, expected: str | None) -> bool: + if not expected: + return True + q = request.args.get("password") + f = request.form.get("password") if hasattr(request, "form") else None + return (q or f) == expected +def _get_str_arg(request: Request, name: str) -> str | None: + return request.form.get(name) or request.args.get(name) + +def attach_endpoints(app: Sanic): @app.websocket("/talk") async def talk_ws_view(request: Request, ws: Websocket) -> HTTPResponse: + if not _check_password(request, app.ctx.ADMIN_PASSWORD): + await ws.close(code=4001, reason="unauthorized") + return while True: serialized_message: dict = await _get_bytes_and_serialize(ws) - await _check_ws_for_close_status( - serialized_message, - ws - ) - new_message = await _generate_new_message( - serialized_message.get("text") - ) + await _check_ws_for_close_status(serialized_message, ws) + new_message = await _generate_new_message(serialized_message.get("text")) MESSAGES_MEMORY_DB.append(new_message) - await ws.send( - str({"status": "ok"}) - ) + await ws.send(str({"status": "ok"})) await asyncio.sleep(0.2) - @app.websocket("/update") async def update_ws_view(request: Request, ws: Websocket) -> HTTPResponse: + if not _check_password(request, app.ctx.ADMIN_PASSWORD): + await ws.close(code=4001, reason="unauthorized") + return while True: - payload = await _generate_update_payload( - MESSAGES_MEMORY_DB, - USERS - ) + payload = await _generate_update_payload(MESSAGES_MEMORY_DB, USERS) await ws.send(payload.encode()) await asyncio.sleep(0.2) - @app.route('/get_key', methods=['GET', 'POST']) async def get_key_view(request: Request) -> HTTPResponse: - public_key = rsa.PublicKey.load_pkcs1(request.form.get('pubkey')) + if not _check_password(request, app.ctx.ADMIN_PASSWORD): + return response.text("unauthorized", status=401) + + pubkey_bytes: bytes | None = None + + if "pubkey" in request.files and request.files.get("pubkey"): + f = request.files.get("pubkey") + if isinstance(f, list): + f = f[0] + pubkey_bytes = f.body + + if pubkey_bytes is None: + raw = request.form.get("pubkey") + if raw: + pubkey_bytes = raw if isinstance(raw, bytes) else str(raw).encode() + + if pubkey_bytes is None: + raw = request.args.get("pubkey") + if raw: + pubkey_bytes = raw.encode() + + if not pubkey_bytes: + return response.text("bad request: pubkey is required", status=400) + + try: + public_key = rsa.PublicKey.load_pkcs1(pubkey_bytes) + except Exception as e: + return response.text(f"bad pubkey: {e}", status=400) + encrypted_data = rsa.encrypt(PUBLIC_KEY, public_key) - if request.ip not in USERS: - USERS[f"{request.ip}, {request.form.get('username')}"] = PUBLIC_KEY + + username = _get_str_arg(request, "username") or "unknown" + user_key = f"{request.ip}, {username}" + if user_key not in USERS: + USERS[user_key] = PUBLIC_KEY + return response.raw(encrypted_data) -def create_app(app_name: str) -> Sanic: +def create_app(app_name: str, admin_password: str | None) -> Sanic: app = Sanic(app_name) + app.ctx.ADMIN_PASSWORD = admin_password attach_endpoints(app) - return app + return app -def run_server(host: str, port: int, dev: bool=False) -> None: - loader = AppLoader(factory=partial(create_app, "CMD_SERVER")) +def run_server(host: str, port: int, dev: bool = False, admin_password: str | None = None) -> None: + loader = AppLoader(factory=partial(create_app, "CMD_SERVER", admin_password)) app = loader.load() app.prepare(host=host, port=port, dev=dev) Sanic.serve(primary=app, app_loader=loader)