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
[](https://opensource.org/licenses/MIT)
-[](https://www.python.org/downloads/)
+[](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
```
-
+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"