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 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..04c4cc2 100644 --- a/cmd_chat/client/client.py +++ b/cmd_chat/client/client.py @@ -1,7 +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 @@ -14,17 +18,36 @@ 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__( - 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() @@ -33,25 +56,52 @@ 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: - 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 +112,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 +143,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 +154,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,30 +167,211 @@ 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("[dim]Type message and press Enter. 'q' to quit.[/]") + self.console.print("-" * 60) + + 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: @@ -162,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": @@ -173,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: @@ -180,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) @@ -196,9 +485,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/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/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 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 39a62e3..5c8fa65 100644 Binary files a/requirements.txt and b/requirements.txt differ 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"