From e7bacc93da7e0811f128274d3e8f66035e7dcb4e Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 25 May 2026 20:30:40 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix(security):=20comprehensive=20security?= =?UTF-8?q?=20hardening=20=E2=80=94=20TLS,=20HMAC=20WS=20auth,=20rate=20li?= =?UTF-8?q?miting,=20IP=20leak=20prevention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL fixes: - Auto-generated self-signed TLS certs (HTTPS/WSS by default) - Removed session_key from /srp/verify response (was sent in plaintext) - Replaced with HMAC-SHA256 ws_token for WebSocket authentication HIGH fixes: - WebSocket auth now validates ws_token via hmac.compare_digest() - /clear endpoint requires Bearer admin_token (printed at server start) - Password no longer required as CLI arg — supports env var + getpass prompt - Removed user_ip from Message model (no longer broadcast to clients) MEDIUM fixes: - Rate limiter on /srp/init and /srp/verify (10 req/min/IP) - MessageStore capped at 1000 messages (prevents RAM DoS) - access_log disabled (was leaking request metadata) LOW fixes: - Username sanitization against rich markup injection - Dead code removed from helpers.py All 79 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd_chat/__init__.py | 41 ++++++++++++++++-- cmd_chat/client/client.py | 71 ++++++++++++++++++++++-------- cmd_chat/server/factory.py | 6 ++- cmd_chat/server/helpers.py | 44 ++++++++----------- cmd_chat/server/models.py | 1 - cmd_chat/server/server.py | 82 ++++++++++++++++++++++++++++++++++- cmd_chat/server/stores.py | 5 ++- cmd_chat/server/views.py | 46 ++++++++++++++------ tests/conftest.py | 4 ++ tests/test_client.py | 11 +++-- tests/test_client_extended.py | 24 +++++----- 11 files changed, 255 insertions(+), 80 deletions(-) diff --git a/cmd_chat/__init__.py b/cmd_chat/__init__.py index c6ea4f6..ef9bc2d 100644 --- a/cmd_chat/__init__.py +++ b/cmd_chat/__init__.py @@ -1,8 +1,19 @@ import argparse +import getpass +import os + from cmd_chat.server.server import run_server from cmd_chat.client.client import Client +def resolve_password(args_password: str | None, prompt: str = "Room password: ") -> str: + if args_password: + return args_password + if env_pw := os.environ.get("CMD_CHAT_PASSWORD"): + return env_pw + return getpass.getpass(prompt) + + def main(): parser = argparse.ArgumentParser(description="Command-line chat application") subparsers = parser.add_subparsers(dest="command", required=True) @@ -10,24 +21,46 @@ def main(): 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) + serve_p.add_argument("--password", "-p", default=None) + serve_p.add_argument("--cert", default=None, help="Path to TLS certificate") + serve_p.add_argument("--key", default=None, help="Path to TLS private key") + serve_p.add_argument("--no-tls", action="store_true", help="Disable TLS (insecure)") 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") + connect_p.add_argument("--password", "-p", default=None) + connect_p.add_argument( + "--insecure", "-k", action="store_true", + help="Skip TLS certificate verification (for self-signed certs)", + ) + connect_p.add_argument( + "--no-tls", action="store_true", + help="Connect without TLS (insecure)", + ) args = parser.parse_args() if args.command == "serve": - run_server(host=args.ip_address, port=int(args.port), password=args.password) + password = resolve_password(args.password) + run_server( + host=args.ip_address, + port=int(args.port), + password=password, + cert_path=args.cert, + key_path=args.key, + no_tls=args.no_tls, + ) elif args.command == "connect": + password = resolve_password(args.password) Client( server=args.ip_address, port=int(args.port), username=args.username, - password=args.password, + password=password, + insecure=args.insecure, + no_tls=args.no_tls, ).run() diff --git a/cmd_chat/client/client.py b/cmd_chat/client/client.py index ea3e67f..b1b9214 100644 --- a/cmd_chat/client/client.py +++ b/cmd_chat/client/client.py @@ -1,5 +1,6 @@ import asyncio import json +import ssl import base64 from typing import Optional @@ -17,14 +18,22 @@ class Client: def __init__( - self, server: str, port: int, username: str, password: Optional[str] = None + self, + server: str, + port: int, + username: str, + password: Optional[str] = None, + insecure: bool = False, + no_tls: bool = False, ): self.server = server self.port = port self.username = username self.password = (password or "").encode() + self.insecure = insecure + self.no_tls = no_tls self.user_id: Optional[str] = None - self.fernet: Optional[Fernet] = None + self.ws_token: Optional[str] = None self.room_fernet: Optional[Fernet] = None self.console = Console() @@ -35,23 +44,42 @@ def __init__( @property def base_url(self) -> str: - return f"http://{self.server}:{self.port}" + scheme = "http" if self.no_tls else "https" + return f"{scheme}://{self.server}:{self.port}" @property def ws_url(self) -> str: - return f"ws://{self.server}:{self.port}" + scheme = "ws" if self.no_tls else "wss" + return f"{scheme}://{self.server}:{self.port}" + + def _request_kwargs(self) -> dict: + kwargs: dict = {"timeout": 30} + if self.insecure and not self.no_tls: + kwargs["verify"] = False + return kwargs + + def _ws_ssl_context(self) -> ssl.SSLContext | None: + if self.no_tls: + return None + if self.insecure: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + return True # default verification def success(self, message: str) -> None: - self.console.print(f"[green]✓ {message}[/]") + self.console.print(f"[green]{message}[/]") def error(self, message: str) -> None: - self.console.print(f"[red]✗ {message}[/]") + self.console.print(f"[red]{message}[/]") def info(self, message: str) -> None: - self.console.print(f"[cyan]• {message}[/]") + self.console.print(f"[cyan]{message}[/]") def srp_authenticate(self) -> None: with self.console.status("[cyan]Starting SRP handshake...[/]", spinner="dots"): + req_kwargs = self._request_kwargs() usr = srp.User(b"chat", self.password, hash_alg=srp.SHA256) _, A = usr.start_authentication() @@ -62,7 +90,7 @@ def srp_authenticate(self) -> None: "username": self.username, "A": base64.b64encode(A).decode(), }, - timeout=30, + **req_kwargs, ) resp.raise_for_status() init_data = resp.json() @@ -93,7 +121,7 @@ def srp_authenticate(self) -> None: "username": self.username, "M": base64.b64encode(M).decode(), }, - timeout=30, + **req_kwargs, ) resp.raise_for_status() verify_data = resp.json() @@ -104,8 +132,7 @@ def srp_authenticate(self) -> None: if not usr.authenticated(): raise ValueError("Server authentication failed") - session_key = base64.b64decode(verify_data["session_key"]) - self.fernet = Fernet(session_key) + self.ws_token = verify_data["ws_token"] self.success(f"SRP authenticated (session: {self.user_id[:8]}...)") @@ -118,29 +145,36 @@ def decrypt_message(self, msg: dict) -> dict: msg["text"] = "[decrypt failed]" return msg + @staticmethod + def _safe_username(username: str) -> str: + return username.replace("[", "\\[") + def render_messages(self) -> None: self.console.clear() - users_online = ", ".join(u.get("username", "?") for u in self.users) or "none" + users_online = ( + ", ".join(self._safe_username(u.get("username", "?")) for u in self.users) + or "none" + ) self.console.print(f"[dim]Online: {users_online}[/]") - self.console.print("─" * 60) + self.console.print("-" * 60) display_messages = ( self.messages[-15:] if len(self.messages) > 15 else self.messages ) for msg in display_messages: - username = msg.get("username", "unknown") + username = self._safe_username(msg.get("username", "unknown")) text = msg.get("text", "") timestamp = str(msg.get("timestamp", ""))[:19].replace("T", " ") - style = "green" if username == self.username else "cyan" + style = "green" if msg.get("username") == self.username else "cyan" self.console.print(f"[dim]{timestamp}[/] [{style}]{username}[/]: {text}") if not display_messages: self.console.print("[dim italic]No messages yet...[/]") - self.console.print("─" * 60) + self.console.print("-" * 60) self.console.print("[dim]Type message and press Enter. 'q' to quit.[/]") async def receive_loop(self, ws) -> None: @@ -196,9 +230,10 @@ async def run_async(self) -> None: self.srp_authenticate() self.info("Connecting to chat...") - url = f"{self.ws_url}/ws/chat?user_id={self.user_id}" + url = f"{self.ws_url}/ws/chat?user_id={self.user_id}&ws_token={self.ws_token}" - async with websockets.connect(url) as ws: + ws_ssl = self._ws_ssl_context() + async with websockets.connect(url, ssl=ws_ssl) as ws: self.success("Connected to chat server") self.running = True diff --git a/cmd_chat/server/factory.py b/cmd_chat/server/factory.py index 894f08c..c40541e 100644 --- a/cmd_chat/server/factory.py +++ b/cmd_chat/server/factory.py @@ -1,12 +1,13 @@ import asyncio +import secrets from contextlib import suppress -from cryptography.fernet import Fernet from sanic import Sanic from sanic_ext import Extend import os from .managers import ConnectionManager from .stores import MessageStore, UserSessionStore from .srp_auth import SRPAuthManager +from .helpers import RateLimiter from .routes import register_routes @@ -20,6 +21,9 @@ def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic: app.ctx.connection_manager = ConnectionManager() app.ctx.srp_manager = SRPAuthManager(password) app.ctx.room_salt = os.urandom(16) + app.ctx.ws_secret = os.urandom(32) + app.ctx.admin_token = secrets.token_hex(16) + app.ctx.rate_limiter = RateLimiter(max_requests=10, window_seconds=60) app.ctx.cleanup_task = None register_lifecycle(app) diff --git a/cmd_chat/server/helpers.py b/cmd_chat/server/helpers.py index db47811..bfd6b7f 100644 --- a/cmd_chat/server/helpers.py +++ b/cmd_chat/server/helpers.py @@ -1,36 +1,21 @@ +import time +from collections import defaultdict from datetime import datetime, timezone -from typing import Optional from dataclasses import asdict import json -from sanic import Sanic, Request, response, Websocket +from sanic import Request, Sanic, Websocket def utcnow() -> datetime: return datetime.now(timezone.utc) -def verify_password(password: Optional[str], expected: Optional[str]) -> bool: - if not expected: - return True - return password == expected - - def get_client_ip(request: Request) -> str: if forwarded := request.headers.get("x-forwarded-for"): return forwarded.split(",")[0].strip() return request.ip -def get_param(request: Request, name: str) -> Optional[str]: - return request.args.get(name) or request.form.get(name) - - -def require_auth(request: Request, app: Sanic) -> Optional[response.HTTPResponse]: - if not verify_password(get_param(request, "password"), app.ctx.admin_password): - return response.text("Unauthorized", status=401) - return None - - async def send_state(ws: Websocket, app: Sanic) -> None: messages = app.ctx.message_store.get_all() users = app.ctx.session_store.get_all() @@ -47,12 +32,17 @@ async def send_state(ws: Websocket, app: Sanic) -> None: ) -def extract_pubkey(request: Request) -> Optional[bytes]: - if files := request.files.get("pubkey"): - file = files[0] if isinstance(files, list) else files - return file.body - if raw := request.form.get("pubkey"): - return raw.encode() if isinstance(raw, str) else raw - if raw := request.args.get("pubkey"): - return raw.encode() if isinstance(raw, str) else raw - return None +class RateLimiter: + def __init__(self, max_requests: int = 10, window_seconds: int = 60): + self.max_requests = max_requests + self.window = window_seconds + self._requests: dict[str, list[float]] = defaultdict(list) + + def is_allowed(self, key: str) -> bool: + now = time.monotonic() + timestamps = self._requests[key] + timestamps[:] = [t for t in timestamps if now - t < self.window] + if len(timestamps) >= self.max_requests: + return False + timestamps.append(now) + return True diff --git a/cmd_chat/server/models.py b/cmd_chat/server/models.py index dce1601..c83b134 100644 --- a/cmd_chat/server/models.py +++ b/cmd_chat/server/models.py @@ -11,7 +11,6 @@ class Message: timestamp: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) - user_ip: str = "" username: str = "" diff --git a/cmd_chat/server/server.py b/cmd_chat/server/server.py index 87e03c0..4985bf2 100644 --- a/cmd_chat/server/server.py +++ b/cmd_chat/server/server.py @@ -1,19 +1,97 @@ +import ipaddress +import ssl +from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import Optional + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec + from .factory import create_app +DEFAULT_CERT_DIR = Path.home() / ".cmd-chat" / "certs" + + +def ensure_tls_certs(cert_dir: Path = DEFAULT_CERT_DIR) -> tuple[Path, Path]: + cert_dir.mkdir(parents=True, exist_ok=True) + cert_path = cert_dir / "server.pem" + key_path = cert_dir / "server-key.pem" + + if cert_path.exists() and key_path.exists(): + return cert_path, key_path + + key = ec.generate_private_key(ec.SECP256R1()) + + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "cmd-chat"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "cmd-chat-self-signed"), + ]) + + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc)) + .not_valid_after(datetime.now(timezone.utc) + timedelta(days=365)) + .add_extension( + x509.SubjectAlternativeName([ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), + ]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + key_path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + + return cert_path, key_path + def run_server( host: str = "0.0.0.0", port: int = 8000, password: Optional[str] = None, - workers: int = 1, + cert_path: Optional[str] = None, + key_path: Optional[str] = None, + no_tls: bool = False, ) -> None: app = create_app(password=password or "") + ssl_ctx = None + if not no_tls: + if cert_path and key_path: + c, k = Path(cert_path), Path(key_path) + else: + c, k = ensure_tls_certs() + print(f"[TLS] Auto-generated self-signed cert: {c}") + + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(str(c), str(k)) + protocol = "https" + else: + print("[WARNING] TLS disabled — all traffic is unencrypted!") + protocol = "http" + + print(f"[ADMIN] Clear token: {app.ctx.admin_token}") + print(f"[SERVER] Listening on {protocol}://{host}:{port}") + app.run( host=host, port=port, single_process=True, debug=False, - access_log=True, + access_log=False, + ssl=ssl_ctx, ) diff --git a/cmd_chat/server/stores.py b/cmd_chat/server/stores.py index ff1ec1b..2b1d60a 100644 --- a/cmd_chat/server/stores.py +++ b/cmd_chat/server/stores.py @@ -3,11 +3,14 @@ class MessageStore: - def __init__(self): + def __init__(self, max_messages: int = 1000): self._messages: list[Message] = [] + self._max = max_messages def add(self, message: Message) -> None: self._messages.append(message) + if len(self._messages) > self._max: + self._messages = self._messages[-self._max:] def get_all(self) -> list[Message]: return self._messages.copy() diff --git a/cmd_chat/server/views.py b/cmd_chat/server/views.py index e2ecd36..a24ee69 100644 --- a/cmd_chat/server/views.py +++ b/cmd_chat/server/views.py @@ -1,21 +1,26 @@ -from dataclasses import asdict - +import hashlib +import hmac import json import base64 +from dataclasses import asdict from sanic import Sanic, Request, response, Websocket from sanic.response import HTTPResponse, json as json_response from .models import Message, UserSession -from .helpers import ( - get_client_ip, - send_state, - utcnow, -) +from .helpers import get_client_ip, send_state, utcnow + + +def generate_ws_token(user_id: str, secret: bytes) -> str: + return hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest() async def srp_init(request: Request, app: Sanic) -> HTTPResponse: try: + client_ip = get_client_ip(request) + if not app.ctx.rate_limiter.is_allowed(client_ip): + return response.json({"error": "Rate limited"}, status=429) + data = request.json or {} username = data.get("username", "unknown") client_public_b64 = data.get("A") @@ -45,6 +50,10 @@ async def srp_init(request: Request, app: Sanic) -> HTTPResponse: async def srp_verify(request: Request, app: Sanic) -> HTTPResponse: try: + client_ip = get_client_ip(request) + if not app.ctx.rate_limiter.is_allowed(client_ip): + return response.json({"error": "Rate limited"}, status=429) + data = request.json or {} user_id = data.get("user_id") client_proof_b64 = data.get("M") @@ -67,10 +76,12 @@ async def srp_verify(request: Request, app: Sanic) -> HTTPResponse: ) app.ctx.session_store.add(session) + ws_token = generate_ws_token(user_id, app.ctx.ws_secret) + return response.json( { "H_AMK": base64.b64encode(H_AMK).decode(), - "session_key": base64.b64encode(fernet_key).decode(), + "ws_token": ws_token, } ) @@ -82,9 +93,15 @@ async def srp_verify(request: Request, app: Sanic) -> HTTPResponse: async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: user_id = request.args.get("user_id") + ws_token = request.args.get("ws_token") + + if not user_id or not ws_token: + await ws.close(code=4002, reason="user_id and ws_token required") + return - if not user_id: - await ws.close(code=4002, reason="user_id required") + expected_token = generate_ws_token(user_id, app.ctx.ws_secret) + if not hmac.compare_digest(ws_token, expected_token): + await ws.close(code=4003, reason="Invalid token") return session = app.ctx.session_store.get(user_id) @@ -106,7 +123,6 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: message = Message( text=str(data), - user_ip=session.ip, username=session.username, ) app.ctx.message_store.add(message) @@ -146,8 +162,12 @@ async def health(request: Request, app: Sanic) -> HTTPResponse: async def clear_messages(request: Request, app: Sanic) -> HTTPResponse: - user_id = request.args.get("user_id") - if not user_id or not app.ctx.session_store.get(user_id): + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return response.json({"error": "Unauthorized"}, status=401) + + token = auth_header[7:] + if not hmac.compare_digest(token, app.ctx.admin_token): return response.json({"error": "Unauthorized"}, status=401) app.ctx.message_store.clear() diff --git a/tests/conftest.py b/tests/conftest.py index d11ae13..dad0516 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,10 @@ def app(): app.ctx.connection_manager = ConnectionManager() app.ctx.srp_manager = SRPAuthManager("testpassword") app.ctx.room_salt = os.urandom(16) + app.ctx.ws_secret = os.urandom(32) + app.ctx.admin_token = "test-admin-token" + from cmd_chat.server.helpers import RateLimiter + app.ctx.rate_limiter = RateLimiter(max_requests=100, window_seconds=60) app.ctx.cleanup_task = None register_routes(app) diff --git a/tests/test_client.py b/tests/test_client.py index 54b338a..edafd9b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -52,14 +52,19 @@ def test_client_creation(self, client): assert client.username == "testuser" assert client.password == b"testpassword" assert client.user_id is None - assert client.fernet is None + assert client.ws_token is None assert client.room_fernet is None assert client.connected is False assert client.running is False def test_client_urls(self, client): - assert client.base_url == "http://127.0.0.1:3000" - assert client.ws_url == "ws://127.0.0.1:3000" + assert client.base_url == "https://127.0.0.1:3000" + assert client.ws_url == "wss://127.0.0.1:3000" + + def test_client_no_tls_urls(self): + c = Client("127.0.0.1", 3000, "user", "pass", no_tls=True) + assert c.base_url == "http://127.0.0.1:3000" + assert c.ws_url == "ws://127.0.0.1:3000" def test_client_empty_password(self): client = Client("localhost", 8080, "user", None) diff --git a/tests/test_client_extended.py b/tests/test_client_extended.py index e9f0d32..0bb95f3 100644 --- a/tests/test_client_extended.py +++ b/tests/test_client_extended.py @@ -48,15 +48,20 @@ def room_fernet(room_salt): class TestClientProperties: def test_base_url_different_ports(self): client = Client("example.com", 8080, "user", "pass") - assert client.base_url == "http://example.com:8080" + assert client.base_url == "https://example.com:8080" def test_ws_url_different_ports(self): client = Client("example.com", 8080, "user", "pass") - assert client.ws_url == "ws://example.com:8080" + assert client.ws_url == "wss://example.com:8080" def test_base_url_localhost(self): client = Client("localhost", 443, "user", "pass") - assert client.base_url == "http://localhost:443" + assert client.base_url == "https://localhost:443" + + def test_no_tls_urls(self): + client = Client("example.com", 8080, "user", "pass", no_tls=True) + assert client.base_url == "http://example.com:8080" + assert client.ws_url == "ws://example.com:8080" def test_password_encoding_unicode(self): client = Client("localhost", 3000, "user", "пароль123") @@ -84,7 +89,7 @@ def test_srp_authenticate_success(self, mock_post, client, room_salt): verify_response = MagicMock() verify_response.json.return_value = { "H_AMK": base64.b64encode(os.urandom(32)).decode(), - "session_key": base64.b64encode(Fernet.generate_key()).decode(), + "ws_token": "test-ws-token-hex", } verify_response.raise_for_status = MagicMock() @@ -102,7 +107,7 @@ def test_srp_authenticate_success(self, mock_post, client, room_salt): assert client.user_id == "test-user-id-12345" assert client.room_fernet is not None - assert client.fernet is not None + assert client.ws_token == "test-ws-token-hex" @patch("cmd_chat.client.client.requests.post") def test_srp_authenticate_init_fails(self, mock_post, client): @@ -178,7 +183,7 @@ def test_srp_authenticate_server_not_authenticated( verify_response = MagicMock() verify_response.json.return_value = { "H_AMK": base64.b64encode(os.urandom(32)).decode(), - "session_key": base64.b64encode(Fernet.generate_key()).decode(), + "ws_token": "test-ws-token-hex", } verify_response.raise_for_status = MagicMock() @@ -237,7 +242,6 @@ def test_decrypt_preserves_other_fields(self, client, room_fernet, room_salt): "username": "sender", "timestamp": "2024-01-01T12:00:00", "id": "msg-123", - "user_ip": "192.168.1.1", } decrypted = client.decrypt_message(msg) @@ -246,7 +250,6 @@ def test_decrypt_preserves_other_fields(self, client, room_fernet, room_salt): assert decrypted["username"] == "sender" assert decrypted["timestamp"] == "2024-01-01T12:00:00" assert decrypted["id"] == "msg-123" - assert decrypted["user_ip"] == "192.168.1.1" def test_decrypt_wrong_key_marks_failed(self, client): @@ -553,6 +556,7 @@ async def test_run_generic_exception(self, client): @pytest.mark.asyncio async def test_run_successful_connection_and_disconnect(self, client): client.user_id = "test-id-123" + client.ws_token = "test-token" with patch.object(client, "srp_authenticate"): with patch("cmd_chat.client.client.websockets.connect") as mock_connect: @@ -844,8 +848,8 @@ def test_special_characters_in_message(self, client, room_fernet, room_salt): def test_port_zero(self): client = Client("localhost", 0, "user", "pass") assert client.port == 0 - assert client.base_url == "http://localhost:0" + assert client.base_url == "https://localhost:0" def test_ipv6_server(self): client = Client("::1", 3000, "user", "pass") - assert client.base_url == "http://::1:3000" + assert client.base_url == "https://::1:3000" From 90491988b8d6b95306e2d0fd6e437ae7a2368e90 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 25 May 2026 21:44:24 -0700 Subject: [PATCH 2/5] feat: add interactive 2-user lab environment + fix requirements.txt encoding - lab/setup-lab.sh: automated tmux setup with server + 2 chat clients Supports --no-tls, --password, --port, --user1/--user2, --teardown Auto-installs missing pip dependencies, verifies port availability, waits for server health before connecting clients - lab/README.md: usage docs and keyboard shortcuts - requirements.txt: fixed UTF-16 encoding to UTF-8, cleaned pinned versions Co-Authored-By: Claude Opus 4.6 (1M context) --- lab/README.md | 55 ++++++++++++ lab/setup-lab.sh | 226 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | Bin 1254 -> 524 bytes 3 files changed, 281 insertions(+) create mode 100644 lab/README.md create mode 100755 lab/setup-lab.sh diff --git a/lab/README.md b/lab/README.md new file mode 100644 index 0000000..a57650e --- /dev/null +++ b/lab/README.md @@ -0,0 +1,55 @@ +# cmd-chat Lab + +Interactive 2-user test environment using tmux. Spins up a server and two chat clients side-by-side so you can send messages between them. + +## Quick Start + +```bash +# Default (TLS, password: labtest) +./lab/setup-lab.sh + +# Plain HTTP (local testing) +./lab/setup-lab.sh --no-tls + +# Custom password and port +./lab/setup-lab.sh --password mysecret --port 5000 + +# Custom usernames +./lab/setup-lab.sh --user1 charlie --user2 dave +``` + +## Attach & Navigate + +```bash +tmux attach -t cmd-chat-lab +``` + +| Key | Action | +|-----|--------| +| `Ctrl+B` then arrow keys | Switch between panes | +| `Ctrl+B` then `z` | Zoom/unzoom current pane | +| `q` | Disconnect from chat | +| `Ctrl+C` in server pane | Stop server | + +## Teardown + +```bash +./lab/setup-lab.sh --teardown +``` + +## Layout + +``` +┌──────────── SERVER (127.0.0.1:4000) ─────────────┐ +│ Sanic running, admin token displayed here │ +├──────── alice ───────┬──────── bob ──────────────┤ +│ Type messages here │ Type messages here │ +│ and hit Enter │ and hit Enter │ +└──────────────────────┴───────────────────────────┘ +``` + +## Requirements + +- Python 3.10+ +- tmux 3.0+ +- All Python deps from `requirements.txt` (auto-installed if missing) diff --git a/lab/setup-lab.sh b/lab/setup-lab.sh new file mode 100755 index 0000000..a248f22 --- /dev/null +++ b/lab/setup-lab.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# +# cmd-chat Lab — Interactive 2-user encrypted chat test environment +# +# Usage: +# ./lab/setup-lab.sh # TLS mode (default, auto-generates self-signed cert) +# ./lab/setup-lab.sh --no-tls # Plain HTTP mode (local testing only) +# ./lab/setup-lab.sh --password secret # Set room password (default: prompted or "labtest") +# ./lab/setup-lab.sh --port 5000 # Custom port (default: 4000) +# ./lab/setup-lab.sh --teardown # Kill existing lab session +# +# Attach to the running lab: +# tmux attach -t cmd-chat-lab +# +# Pane navigation: +# Ctrl+B then arrow keys — switch between panes +# Ctrl+B then z — zoom/unzoom current pane +# Type 'q' in a chat pane — disconnect that user +# Ctrl+C in server pane — stop server +# + +set -euo pipefail + +# ─── Configuration ─────────────────────────────────────────────────────────── + +SESSION="cmd-chat-lab" +PORT="${PORT:-4000}" +PASSWORD="" +NO_TLS=false +TEARDOWN=false +USER1="alice" +USER2="bob" + +# ─── Parse Arguments ──────────────────────────────────────────────────────── + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-tls) NO_TLS=true; shift ;; + --password) PASSWORD="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --user1) USER1="$2"; shift 2 ;; + --user2) USER2="$2"; shift 2 ;; + --teardown) TEARDOWN=true; shift ;; + -h|--help) + head -20 "$0" | grep '^#' | sed 's/^# \?//' + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# ─── Resolve project root ─────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ─── Teardown ──────────────────────────────────────────────────────────────── + +if $TEARDOWN; then + if tmux has-session -t "$SESSION" 2>/dev/null; then + tmux kill-session -t "$SESSION" + echo "Killed session: $SESSION" + else + echo "No session to kill." + fi + exit 0 +fi + +# ─── Pre-flight checks ────────────────────────────────────────────────────── + +check_command() { + if ! command -v "$1" &>/dev/null; then + echo "ERROR: $1 not found. Install it first." >&2 + exit 1 + fi +} + +check_command tmux +check_command python3 + +# Check dependencies +echo "Checking Python dependencies..." +MISSING=() +for pkg in sanic srp cryptography websockets rich requests; do + if ! python3 -c "import $pkg" 2>/dev/null; then + MISSING+=("$pkg") + fi +done + +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "Installing missing packages: ${MISSING[*]}" + python3 -m pip install -r "$PROJECT_ROOT/requirements.txt" --quiet 2>/dev/null + echo "Dependencies installed." +else + echo "All dependencies OK." +fi + +# Check if port is available +if ss -tlnp 2>/dev/null | grep -q ":${PORT} " || lsof -i ":${PORT}" &>/dev/null; then + echo "ERROR: Port $PORT is already in use." >&2 + echo " Try: $0 --port 4001" >&2 + exit 1 +fi + +# ─── Password resolution ──────────────────────────────────────────────────── + +if [[ -z "$PASSWORD" ]]; then + if [[ -n "${CMD_CHAT_PASSWORD:-}" ]]; then + PASSWORD="$CMD_CHAT_PASSWORD" + else + PASSWORD="labtest" + echo "Using default password: $PASSWORD" + fi +fi +export CMD_CHAT_PASSWORD="$PASSWORD" + +# ─── Kill existing session if any ──────────────────────────────────────────── + +if tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Killing existing $SESSION session..." + tmux kill-session -t "$SESSION" + sleep 1 +fi + +# ─── Build TLS flags ──────────────────────────────────────────────────────── + +SERVER_FLAGS="" +CLIENT_FLAGS="" +if $NO_TLS; then + SERVER_FLAGS="--no-tls" + CLIENT_FLAGS="--no-tls" + PROTO="http" +else + CLIENT_FLAGS="--insecure" + PROTO="https" +fi + +# ─── Create tmux session ──────────────────────────────────────────────────── + +echo "" +echo "Setting up cmd-chat lab..." +echo " Server: $PROTO://127.0.0.1:$PORT" +echo " Users: $USER1, $USER2" +echo " TLS: $( $NO_TLS && echo 'disabled' || echo 'enabled (self-signed)' )" +echo "" + +# Pane 1: Server (top) +tmux new-session -d -s "$SESSION" -n chat -c "$PROJECT_ROOT" -x 200 -y 50 + +# Pane 2: Client alice (bottom-left) +tmux split-window -v -t "$SESSION:chat" -p 75 -c "$PROJECT_ROOT" 2>/dev/null || \ + tmux split-window -v -t "$SESSION:chat" -c "$PROJECT_ROOT" + +# Pane 3: Client bob (bottom-right) +tmux split-window -h -t "$SESSION:chat" -c "$PROJECT_ROOT" 2>/dev/null || true + +# Get pane IDs +PANES=($(tmux list-panes -t "$SESSION:chat" -F '#{pane_id}')) +SERVER_PANE="${PANES[0]}" +ALICE_PANE="${PANES[1]}" +BOB_PANE="${PANES[2]}" + +# Label panes +tmux select-pane -t "$SERVER_PANE" -T "SERVER ($PROTO://127.0.0.1:$PORT)" +tmux select-pane -t "$ALICE_PANE" -T "$USER1" +tmux select-pane -t "$BOB_PANE" -T "$USER2" +tmux set -t "$SESSION" pane-border-format " #{pane_title} " +tmux set -t "$SESSION" pane-border-status top + +# Resize server pane smaller +tmux resize-pane -t "$SERVER_PANE" -y 10 + +# ─── Start server ─────────────────────────────────────────────────────────── + +tmux send-keys -t "$SERVER_PANE" \ + "cd '$PROJECT_ROOT' && CMD_CHAT_PASSWORD='$PASSWORD' python3 cmd_chat.py serve 127.0.0.1 $PORT $SERVER_FLAGS" Enter + +# Wait for server to be ready +echo -n "Waiting for server" +for i in $(seq 1 15); do + if $NO_TLS; then + curl -sf "http://127.0.0.1:$PORT/health" &>/dev/null && break + else + curl -sfk "https://127.0.0.1:$PORT/health" &>/dev/null && break + fi + echo -n "." + sleep 1 +done +echo " ready!" + +# ─── Connect clients ──────────────────────────────────────────────────────── + +tmux send-keys -t "$ALICE_PANE" \ + "cd '$PROJECT_ROOT' && CMD_CHAT_PASSWORD='$PASSWORD' python3 cmd_chat.py connect 127.0.0.1 $PORT $USER1 $CLIENT_FLAGS" Enter + +sleep 2 + +tmux send-keys -t "$BOB_PANE" \ + "cd '$PROJECT_ROOT' && CMD_CHAT_PASSWORD='$PASSWORD' python3 cmd_chat.py connect 127.0.0.1 $PORT $USER2 $CLIENT_FLAGS" Enter + +sleep 2 + +# ─── Verify ───────────────────────────────────────────────────────────────── + +ALICE_OUT=$(tmux capture-pane -t "$ALICE_PANE" -p 2>/dev/null) +BOB_OUT=$(tmux capture-pane -t "$BOB_PANE" -p 2>/dev/null) + +if echo "$ALICE_OUT" | grep -q "Online:" && echo "$BOB_OUT" | grep -q "Online:"; then + echo "" + echo "Lab is running! Both users connected." + echo "" + echo " Attach: tmux attach -t $SESSION" + echo " Teardown: $0 --teardown" + echo "" + echo " Switch panes: Ctrl+B then arrow keys" + echo " Zoom pane: Ctrl+B then z" + echo " Quit chat: type 'q' in a chat pane" + echo "" +else + echo "" + echo "WARNING: One or both clients may not have connected." + echo "Attach and check: tmux attach -t $SESSION" +fi diff --git a/requirements.txt b/requirements.txt index 39a62e32bc0898ff78191e53a763015a72827431..5c8fa6552743158a4bff21818a9de359e853520f 100644 GIT binary patch literal 524 zcmZ{hO>Tog429o%PEo1rphD7Sm7by(Na-k`q(BC$Cb#eB8UC`UMhLK<{XTno9o48- zsRO6bt1{)hGrh>EpR*fz-877<-M^}!RzK)!x}HY$_CZ}L=&lY;iz49AsUq@@dZ)MO zJh=yQ#+8rJ0y0O9*A*8J-@s#)M!cQ5JNkV^b|Wcg#WGol@hWC=e9bYr{?hl8t}S(E zz9)w~?q*U|#J_bRHsEvOc({zjSZ!k4-w@|3W3J}U&m@z==~++uAY-$ntvRN?N43SQ k=YGYv&Ta7S(KfKw5S!Kri*aL*H)#HgOS*5HJnh`+4>J5tN&o-= literal 1254 zcmZvcPjA{#5XARfDIdiMQ<64^RO&sIdhIDPhJuB$iR~zDKYW|t?D8Hoi4cEx-t5fm zKK}i!tg+sH^4i+QM!Vy&uxp!H$#ZU3_Q5Lac=R^$X#%yjg7@Ec%qQkxKV^K)?*oX& zd4qADS;brOP0Te2^$v{srJl9z=r*8e15@Gs2Rrav3;vIH(5a7{So$;g2G=#AcFXAs zjXQo3a%nftKI4qAJ9rPUUGrXn-eyiI6mPf}Zh}XgJ^08dIWC>6A(IZ4q^QK9dPwhJ zd#b%XgDB80H^;2$((?h_6ZqD7)ImxjsRNakv+C54+vFHII_ECd8t#|1wF~pZzS=jt&#L7vG>Q2U zciO%wZ=kAYO@n*h`RUTT z;(xq0^XbiIeD*Dt`^4MORUEPT=Xs6vIp&HIS)8UlQK@jGd!&syXCEpwr?`QkPtic{rt^$&IHIF+br;{|^WUv6H%YE2a`@%=|V5>oZI%IC^^T z`JAShIK-AVOI|eRFYrVXDJSh+sus8(`QE`OFG5E?$}r`sJ=WayZxpPT^OB3W@h|o* BxAy=5 From 65ee9dee16f295e7402fb911d6bb5455caef31a0 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 25 May 2026 22:40:57 -0700 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20add=20host-chat.sh=20=E2=80=94=20on?= =?UTF-8?q?e-command=20server=20setup=20with=20friend=20instructions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects all available IPs (Tailscale, LAN, public), prints connect command for friends to copy, prompts for password securely via getpass. Co-Authored-By: Claude Opus 4.6 (1M context) --- lab/host-chat.sh | 96 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100755 lab/host-chat.sh diff --git a/lab/host-chat.sh b/lab/host-chat.sh new file mode 100755 index 0000000..9621bcd --- /dev/null +++ b/lab/host-chat.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# host-chat.sh — Start cmd-chat server for remote friends +# +# Usage: +# ./lab/host-chat.sh # TLS on port 4000, prompts for password +# ./lab/host-chat.sh --port 5000 # Custom port +# ./lab/host-chat.sh --no-tls # Disable TLS (not recommended) +# +# After starting, give your friend: +# python3 cmd_chat.py connect YOUR_IP PORT theirname --insecure +# + +set -euo pipefail + +PORT="${1:-4000}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +NO_TLS=false + +# Parse args +while [[ $# -gt 0 ]]; do + case "$1" in + --port) PORT="$2"; shift 2 ;; + --no-tls) NO_TLS=true; shift ;; + *) shift ;; + esac +done + +cd "$PROJECT_ROOT" + +# Check deps +echo "Checking dependencies..." +for pkg in sanic srp cryptography websockets rich requests; do + if ! python3 -c "import $pkg" 2>/dev/null; then + echo "Installing missing dependencies..." + python3 -m pip install -r requirements.txt --quiet 2>/dev/null + break + fi +done + +# Get all IPs for display +echo "" +echo "=========================================" +echo " cmd-chat Server Setup" +echo "=========================================" +echo "" +echo "Your IP addresses:" + +# Tailscale IP +TS_IP=$(tailscale ip -4 2>/dev/null || true) +if [[ -n "$TS_IP" ]]; then + echo " Tailscale: $TS_IP (recommended — encrypted tunnel)" +fi + +# LAN IPs +for iface in $(ip -4 -o addr show scope global 2>/dev/null | awk '{print $2}' | sort -u); do + IP=$(ip -4 -o addr show "$iface" 2>/dev/null | awk '{print $4}' | cut -d/ -f1) + echo " $iface: $IP" +done + +# Public IP +PUB_IP=$(curl -sf --max-time 3 ifconfig.me 2>/dev/null || true) +if [[ -n "$PUB_IP" ]]; then + echo " Public: $PUB_IP (requires port forwarding)" +fi + +echo "" +echo "=========================================" + +# TLS flags +SERVER_FLAGS="" +PROTO="https" +CLIENT_FLAG="--insecure" +if $NO_TLS; then + SERVER_FLAGS="--no-tls" + PROTO="http" + CLIENT_FLAG="--no-tls" +fi + +# Pick best IP for friend instructions +BEST_IP="${TS_IP:-$(ip -4 -o addr show scope global 2>/dev/null | head -1 | awk '{print $4}' | cut -d/ -f1)}" + +echo "" +echo "Tell your friend to run:" +echo "" +echo " git clone https://github.com/leetcrypt/cmd-chat.git" +echo " cd cmd-chat && pip install -r requirements.txt" +echo " python3 cmd_chat.py connect $BEST_IP $PORT THEIR_NAME $CLIENT_FLAG" +echo "" +echo "They'll be prompted for the password (same one you're about to set)." +echo "=========================================" +echo "" + +# Start server — password will be prompted by cmd_chat +exec python3 cmd_chat.py serve 0.0.0.0 "$PORT" $SERVER_FLAGS From 70ddca8a1fb693abdaafb61705a0c71f62bd52a6 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Tue, 26 May 2026 00:01:51 -0700 Subject: [PATCH 4/5] feat: encrypted file transfer with propose/accept flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New commands: /send , /accept, /reject Protocol: - Sender proposes file (name, size, SHA-256 hash) - Recipient sees offer and chooses /accept or /reject - On accept: file chunked (64KB), encrypted with room key, sent over WebSocket - On receive: chunks reassembled, SHA-256 verified, saved to ./downloads/ - Server never sees file content (E2E encrypted, same as messages) Limits: 50MB max file size. Files saved with collision-safe naming. No server changes — server remains a dumb encrypted relay. All 79 existing tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd_chat/client/client.py | 257 +++++++++++++++++++++++++++++++++++++- 1 file changed, 256 insertions(+), 1 deletion(-) diff --git a/cmd_chat/client/client.py b/cmd_chat/client/client.py index b1b9214..04c4cc2 100644 --- a/cmd_chat/client/client.py +++ b/cmd_chat/client/client.py @@ -1,8 +1,11 @@ import asyncio +import hashlib import json import ssl import base64 +from pathlib import Path from typing import Optional +from uuid import uuid4 import srp import requests @@ -15,6 +18,17 @@ srp.rfc5054_enable() +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB +CHUNK_SIZE = 64 * 1024 # 64 KB + + +def _human_size(size: int) -> str: + for unit in ("B", "KB", "MB", "GB"): + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + class Client: def __init__( @@ -42,6 +56,14 @@ def __init__( self.connected = False self.running = False + # File transfer state + self.pending_offer: Optional[dict] = None + self.active_send: Optional[dict] = None # {id, path} for outgoing + self.received_chunks: dict[str, list[bytes]] = {} + self.transfer_meta: dict[str, dict] = {} # id -> offer metadata + self.download_dir = Path("./downloads") + self._ws_ref: Optional[object] = None # WebSocket reference for sending from input_loop + @property def base_url(self) -> str: scheme = "http" if self.no_tls else "https" @@ -175,7 +197,181 @@ def render_messages(self) -> None: self.console.print("[dim italic]No messages yet...[/]") self.console.print("-" * 60) - self.console.print("[dim]Type message and press Enter. 'q' to quit.[/]") + + if self.pending_offer: + name = self.pending_offer.get("name", "?") + size = _human_size(self.pending_offer.get("size", 0)) + sender = self._safe_username(self.pending_offer.get("from", "?")) + self.console.print( + f"[yellow bold]{sender}[/] wants to send [bold]{name}[/] ({size})" + f" — [green]/accept[/] or [red]/reject[/]" + ) + self.console.print("-" * 60) + + self.console.print( + "[dim]/send | /accept | /reject | q to quit[/]" + ) + + # ── File Transfer: Sending ─────────────────────────────────────────── + + async def _send_offer(self, ws, filepath: str) -> None: + path = Path(filepath).expanduser().resolve() + if not path.is_file(): + self.error(f"File not found: {filepath}") + return + + size = path.stat().st_size + if size > MAX_FILE_SIZE: + self.error(f"File too large ({_human_size(size)}). Max is {_human_size(MAX_FILE_SIZE)}") + return + + sha = hashlib.sha256() + with open(path, "rb") as f: + while chunk := f.read(CHUNK_SIZE): + sha.update(chunk) + + transfer_id = str(uuid4()) + self.active_send = {"id": transfer_id, "path": str(path)} + + offer = json.dumps({ + "_ft": "offer", + "id": transfer_id, + "name": path.name, + "size": size, + "sha256": sha.hexdigest(), + }) + encrypted = self.room_fernet.encrypt(offer.encode()).decode() + await ws.send(encrypted) + self.info(f"Offered {path.name} ({_human_size(size)}) — waiting for accept...") + + async def _send_file_chunks(self, ws, transfer_id: str, filepath: str) -> None: + path = Path(filepath) + total = path.stat().st_size + sent = 0 + seq = 0 + + with open(path, "rb") as f: + while chunk := f.read(CHUNK_SIZE): + msg = json.dumps({ + "_ft": "chunk", + "id": transfer_id, + "seq": seq, + "data": base64.b64encode(chunk).decode(), + }) + encrypted = self.room_fernet.encrypt(msg.encode()).decode() + await ws.send(encrypted) + sent += len(chunk) + seq += 1 + pct = int(sent * 100 / total) if total else 100 + self.console.print(f"\r[cyan]Sending: {pct}% ({_human_size(sent)}/{_human_size(total)})[/]", end="") + await asyncio.sleep(0.01) # yield to event loop + + done_msg = json.dumps({"_ft": "done", "id": transfer_id}) + encrypted = self.room_fernet.encrypt(done_msg.encode()).decode() + await ws.send(encrypted) + self.console.print() + self.success(f"File sent: {path.name}") + self.active_send = None + + # ── File Transfer: Receiving ───────────────────────────────────────── + + def _handle_file_protocol(self, ft_data: dict, sender: str) -> bool: + """Handle a file transfer protocol message. Returns True if handled.""" + ft_type = ft_data.get("_ft") + transfer_id = ft_data.get("id") + + if ft_type == "offer": + if sender == self.username: + return True # ignore our own offer echo + self.pending_offer = { + "id": transfer_id, + "name": ft_data.get("name"), + "size": ft_data.get("size"), + "sha256": ft_data.get("sha256"), + "from": sender, + } + self.transfer_meta[transfer_id] = self.pending_offer + self.received_chunks[transfer_id] = [] + self.render_messages() + return True + + elif ft_type == "accept": + if self.active_send and self.active_send["id"] == transfer_id: + # Someone accepted our offer — start sending chunks + asyncio.create_task( + self._send_file_chunks( + self._ws_ref, + self.active_send["id"], + self.active_send["path"], + ) + ) + return True + + elif ft_type == "reject": + if self.active_send and self.active_send["id"] == transfer_id: + self.error(f"{sender} rejected the file transfer.") + self.active_send = None + if self.pending_offer and self.pending_offer["id"] == transfer_id: + self.pending_offer = None + self.render_messages() + return True + + elif ft_type == "chunk": + if transfer_id in self.received_chunks: + chunk_data = base64.b64decode(ft_data.get("data", "")) + self.received_chunks[transfer_id].append(chunk_data) + meta = self.transfer_meta.get(transfer_id, {}) + total = meta.get("size", 0) + received = sum(len(c) for c in self.received_chunks[transfer_id]) + pct = int(received * 100 / total) if total else 0 + self.console.print( + f"\r[cyan]Receiving: {pct}% ({_human_size(received)}/{_human_size(total)})[/]", + end="", + ) + return True + + elif ft_type == "done": + if transfer_id in self.received_chunks: + self._finalize_receive(transfer_id) + return True + + return False + + def _finalize_receive(self, transfer_id: str) -> None: + meta = self.transfer_meta.get(transfer_id, {}) + chunks = self.received_chunks.pop(transfer_id, []) + file_data = b"".join(chunks) + + # Verify integrity + actual_sha = hashlib.sha256(file_data).hexdigest() + expected_sha = meta.get("sha256", "") + if actual_sha != expected_sha: + self.console.print() + self.error(f"SHA-256 mismatch! File corrupted. Expected {expected_sha[:16]}..., got {actual_sha[:16]}...") + return + + # Save file + self.download_dir.mkdir(parents=True, exist_ok=True) + filename = meta.get("name", f"file_{transfer_id[:8]}") + save_path = self.download_dir / filename + + # Avoid overwriting — append number if exists + if save_path.exists(): + stem = save_path.stem + suffix = save_path.suffix + i = 1 + while save_path.exists(): + save_path = self.download_dir / f"{stem}_{i}{suffix}" + i += 1 + + save_path.write_bytes(file_data) + self.console.print() + self.success(f"File saved: {save_path} ({_human_size(len(file_data))}) — SHA-256 verified") + self.pending_offer = None + self.transfer_meta.pop(transfer_id, None) + self.render_messages() + + # ── Core Loops ─────────────────────────────────────────────────────── async def receive_loop(self, ws) -> None: try: @@ -196,6 +392,18 @@ async def receive_loop(self, ws) -> None: self.render_messages() elif msg_type == "message": msg_data = self.decrypt_message(data.get("data", {})) + text = msg_data.get("text", "") + sender = msg_data.get("username", "unknown") + + # Check if this is a file transfer protocol message + if text.startswith('{"_ft":'): + try: + ft_data = json.loads(text) + if self._handle_file_protocol(ft_data, sender): + continue + except json.JSONDecodeError: + pass + self.messages.append(msg_data) self.render_messages() elif msg_type == "user_left": @@ -207,6 +415,7 @@ async def receive_loop(self, ws) -> None: self.connected = False async def input_loop(self, ws) -> None: + self._ws_ref = ws loop = asyncio.get_event_loop() while self.running: try: @@ -214,6 +423,52 @@ async def input_loop(self, ws) -> None: if text.lower() in ("q", "quit", "exit"): self.running = False break + + # File transfer commands + if text.startswith("/send "): + filepath = text[6:].strip() + if filepath: + await self._send_offer(ws, filepath) + else: + self.error("Usage: /send ") + continue + + if text.strip() == "/accept": + if self.pending_offer: + accept_msg = json.dumps({ + "_ft": "accept", + "id": self.pending_offer["id"], + }) + encrypted = self.room_fernet.encrypt(accept_msg.encode()).decode() + await ws.send(encrypted) + self.info(f"Accepted transfer: {self.pending_offer['name']}") + self.pending_offer = None + self.render_messages() + else: + self.error("No pending file offer to accept.") + continue + + if text.strip() == "/reject": + if self.pending_offer: + reject_msg = json.dumps({ + "_ft": "reject", + "id": self.pending_offer["id"], + }) + encrypted = self.room_fernet.encrypt(reject_msg.encode()).decode() + await ws.send(encrypted) + self.info("Rejected file transfer.") + self.received_chunks.pop(self.pending_offer["id"], None) + self.transfer_meta.pop(self.pending_offer["id"], None) + self.pending_offer = None + self.render_messages() + else: + self.error("No pending file offer to reject.") + continue + + if text.strip().startswith("/"): + self.error(f"Unknown command: {text.strip().split()[0]}") + continue + if text.strip(): encrypted = self.room_fernet.encrypt(text.encode()).decode() await ws.send(encrypted) From dc1b5e5ccf32b1e47b9f1d8311546bdfb1760644 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Tue, 26 May 2026 10:57:39 -0700 Subject: [PATCH 5/5] docs: rewrite README with complete setup, security, and file transfer guide Clear, concise documentation covering installation, hosting, connection security (Tailscale/LAN/public), password sharing, file transfer protocol, CLI reference, helper scripts, and architecture overview. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.MD | 269 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 176 insertions(+), 93 deletions(-) diff --git a/README.MD b/README.MD index 4ae118d..f5dd531 100644 --- a/README.MD +++ b/README.MD @@ -1,133 +1,216 @@
-# 🤐 CMD-CHAT +# CMD-CHAT -### encrypted terminal chat. no servers. no logs. ram only. +### end-to-end encrypted terminal chat with file transfer [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
--- -peer-to-peer encrypted chat that runs in your terminal. you host, you control. close the window — everything's gone. +Encrypted chat that runs in your terminal. You host the server, you control the room. Close the window — everything's gone. Messages and files are encrypted client-side before the server ever sees them. -## why +## Features -every "secure" messenger still stores metadata somewhere. this doesn't. it's just two terminals talking over an encrypted tunnel. nothing written to disk, ever. +- **End-to-end encrypted** — messages encrypted with Fernet (AES-128-CBC + HMAC) before leaving your machine +- **TLS by default** — auto-generated self-signed certs, or bring your own +- **SRP authentication** — password never sent over the network (zero-knowledge proof) +- **Encrypted file transfer** — `/send`, `/accept`, `/reject` with SHA-256 verification +- **RAM only** — nothing written to disk on the server +- **Rate limiting** — brute-force protection on auth endpoints +- **No IP leaks** — client IPs never broadcast to other users +- **Password hidden** — prompted securely via `getpass`, never visible in `ps` or shell history -## how it works +## Install +```bash +git clone https://github.com/diorwave/cmd-chat.git +cd cmd-chat +pip install -r requirements.txt ``` -┌──────────────────────────────────────────────────────────────────┐ -│ SRP AUTHENTICATION │ -├──────────────────────────────────────────────────────────────────┤ -│ │ -│ CLIENT SERVER │ -│ │ │ │ -│ │─────── POST /srp/init {username, A} ───────► │ │ -│ │ (A = client public ephemeral) │ │ -│ │ │ │ -│ │◄──── {user_id, B, salt, room_salt} ───────── │ │ -│ │ (B = server public ephemeral) │ │ -│ │ (room_salt = E2E key derivation) │ │ -│ │ │ │ -│ │ [client derives room_key via HKDF: │ │ -│ │ room_key = HKDF(password, room_salt)] │ │ -│ │ │ │ -│ │ [both sides compute SRP session key │ │ -│ │ using password + ephemeral values] │ │ -│ │ │ │ -│ │─────── POST /srp/verify {user_id, M} ──────► │ │ -│ │ (M = client proof) │ │ -│ │ │ │ -│ │◄────────── {H_AMK, session_key} ──────────── │ │ -│ │ (H_AMK = server proof) │ │ -│ │ │ │ -│ │ [password never transmitted] │ │ -│ │ [MITM can't derive session key] │ │ -│ │ │ │ -├──────────────────────────────────────────────────────────────────┤ -│ E2E ENCRYPTED CHAT │ -├──────────────────────────────────────────────────────────────────┤ -│ │ │ │ -│ │═══════ WebSocket /ws/chat?user_id ═════════► │ │ -│ │ (authenticated session) │ │ -│ │ │ │ -│ │ │ │ -│ ┌─┴─┐ ┌──┴──┐ │ -│ │ C │──── encrypt(msg, room_key) ───────────►│ S │ │ -│ │ L │ │ E │ │ -│ │ I │◄─── ciphertext (broadcast) ────────────│ R │ │ -│ │ E │ │ V │ │ -│ │ N │ decrypt(ciphertext, room_key) │ E │ │ -│ │ T │ │ R │ │ -│ └─┬─┘ └──┬──┘ │ -│ │ │ │ -│ │ [server stores ONLY ciphertext] │ │ -│ │ [server CANNOT read messages] │ │ -│ │ [all clients with same password │ │ -│ │ derive identical room_key] │ │ -│ │ │ │ -│ │ Encryption: Fernet (AES-128-CBC + HMAC) │ │ -│ │ Key derivation: HKDF-SHA256 │ │ -│ │ │ │ -│ │ [on disconnect: keys wiped from RAM] │ │ -│ │ │ │ -└──────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────┐ -│ KEY HIERARCHY │ -├──────────────────────────────────────────────────────────────────┤ -│ │ -│ password ──┬──► SRP ──► session_key (per-user, auth only) │ -│ │ │ -│ └──► HKDF(password, room_salt) ──► room_key (shared) │ -│ │ -│ room_salt: generated once at server start │ -│ room_key: deterministic, same for all clients with same pwd │ -│ │ -└──────────────────────────────────────────────────────────────────┘ + +## Quick Start + +**Host a chat room:** + +```bash +python3 cmd_chat.py serve 0.0.0.0 3000 ``` -**SRP (Secure Remote Password)** — password is never sent over the network. both sides prove they know it via zero-knowledge proof, then derive identical session keys. +You'll be prompted for a room password (hidden input). An admin token and TLS cert path will print to the console. -## install +**Connect to a chat room:** ```bash -python -m venv venv && source venv/bin/activate && pip install -r requirements.txt +python3 cmd_chat.py connect SERVER_IP 3000 yourname --insecure ``` -windows: +`--insecure` is needed for self-signed certs. You'll be prompted for the room password. + +## Securing Your Connection + +### Tailscale (recommended) + +Both parties install [Tailscale](https://tailscale.com). Traffic goes through an encrypted WireGuard tunnel. No port forwarding, works across NATs. ```bash -python -m venv venv ; .\venv\Scripts\activate ; pip install -r requirements.txt +# Host +python3 cmd_chat.py serve 0.0.0.0 3000 + +# Friend connects using your Tailscale IP +python3 cmd_chat.py connect 100.x.x.x 3000 theirname --insecure ``` -## usage +Find your Tailscale IP: `tailscale ip -4` + +### LAN (same network) -start server: +Use your local IP. Both devices must be on the same WiFi/network. ```bash -python cmd_chat.py serve 0.0.0.0 3000 --password mysecret +python3 cmd_chat.py connect 192.168.1.x 3000 theirname --insecure ``` -connect: +### Public Internet + +Requires port forwarding on your router (TCP port 3000 to your machine). Use `--cert` and `--key` with a real certificate for production use. ```bash -python cmd_chat.py connect SERVER_IP 3000 username mysecret +python3 cmd_chat.py connect PUBLIC_IP 3000 theirname --insecure ``` -![Example](example.gif) +Find your public IP: `curl ifconfig.me` + +## Sharing the Room Password + +The password must be shared outside the chat. Never send it over an unencrypted channel. + +1. **In person** — tell them verbally +2. **Signal** — disappearing message set to 30 seconds +3. **One-time link** — [onetimesecret.com](https://onetimesecret.com) (self-destructs after one view) +4. **Split it** — send half via Telegram, half via SMS + +## Chat Commands -## features +| Command | Action | +|---------|--------| +| `/send ` | Propose a file transfer to the room | +| `/accept` | Accept a pending file offer | +| `/reject` | Decline a pending file offer | +| `q` | Disconnect | -- **ram only** — nothing touches disk -- **rsa + aes** — key exchange + symmetric encryption -- **no central server** — direct p2p connection -- **srp auth** — password never sent over network +### File Transfer + +Files are chunked (64KB), encrypted with the room key, and relayed through the server as opaque ciphertext. The server never sees file names, contents, or metadata. + +``` +alice> /send report.pdf +bob> "alice wants to send report.pdf (1.2 MB) — /accept or /reject" +bob> /accept + Receiving: 100% (1.2 MB/1.2 MB) + File saved: ./downloads/report.pdf — SHA-256 verified +``` + +- Max file size: 50 MB +- Files saved to `./downloads/` relative to where the client was launched +- SHA-256 integrity check on every transfer + +## CLI Reference + +### Server + +```bash +python3 cmd_chat.py serve [options] +``` + +| Flag | Purpose | +|------|---------| +| `--password`, `-p` | Room password (prompted if omitted) | +| `--cert` | Path to TLS certificate | +| `--key` | Path to TLS private key | +| `--no-tls` | Disable TLS (local dev only) | + +### Client + +```bash +python3 cmd_chat.py connect [options] +``` + +| Flag | Purpose | +|------|---------| +| `--password`, `-p` | Room password (prompted if omitted) | +| `--insecure`, `-k` | Skip TLS cert verification (self-signed certs) | +| `--no-tls` | Connect without TLS | + +### Environment Variable + +Set `CMD_CHAT_PASSWORD` to skip the password prompt for both server and client. + +## Helper Scripts + +### `./lab/host-chat.sh` + +One-command server setup. Detects your IPs (Tailscale, LAN, public), prints the exact connect command your friend needs, then starts the server. + +```bash +./lab/host-chat.sh # TLS on port 4000 +./lab/host-chat.sh --port 5000 # custom port +./lab/host-chat.sh --no-tls # disable TLS +``` + +### `./lab/setup-lab.sh` + +Spins up a tmux session with the server and two chat clients side-by-side for local testing. + +```bash +./lab/setup-lab.sh # default lab +./lab/setup-lab.sh --no-tls --port 4001 # plain HTTP +./lab/setup-lab.sh --user1 alice --user2 bob +./lab/setup-lab.sh --teardown # clean up +``` + +Attach with `tmux attach -t cmd-chat-lab`. Switch panes with `Ctrl+B` then arrow keys. + +## How It Works + +``` +CLIENT SERVER CLIENT + │ │ │ + │── POST /srp/init {A} ──────────► │ │ + │◄── {B, salt, room_salt} ──────── │ │ + │ │ │ + │ derive room_key = HKDF(password, room_salt) │ + │ │ │ + │── POST /srp/verify {M} ────────► │ │ + │◄── {H_AMK, ws_token} ─────────── │ │ + │ │ │ + │══ WSS /ws/chat?ws_token ════════► │ ◄══════════════════════════════│ + │ │ │ + │ encrypt(msg, room_key) ────────► │ ──── ciphertext ────────────► │ + │ │ decrypt(ciphertext, room_key) + │ │ │ + │ server stores ONLY ciphertext │ │ + │ server CANNOT read messages │ │ +``` + +**SRP (Secure Remote Password)** — both sides prove they know the password without transmitting it. A network observer learns nothing. + +**Room Key** — derived independently by each client via `HKDF(password, room_salt)`. All clients with the same password get the same key. The server never has the key. + +**WebSocket Auth** — HMAC-SHA256 token issued after SRP verification. Prevents session hijacking. + +## Admin + +The server prints an admin token at startup. Use it to clear message history: + +```bash +curl -k -X DELETE https://SERVER:3000/clear \ + -H "Authorization: Bearer " +``` -## license +## License MIT