diff --git a/.gitignore b/.gitignore index 48efe41..122f55e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ __pycache__ cmd_chat.egg-info build dist -secured_console_chat.egg-info \ No newline at end of file +true.txt +secured_console_chat.egg-info +.pytest_cache/ \ No newline at end of file diff --git a/README.MD b/README.MD index 6f12ae0..69be7e4 100644 --- a/README.MD +++ b/README.MD @@ -1,98 +1,106 @@
-# 🀐 CMD-CHAT: The "Ghost" Protocol +# 🀐 CMD-CHAT -### The chat tool your ISP doesn't want you to have. - -### Zero Logs. Zero Disk Writes. 100% Deniable. +### encrypted terminal chat. no servers. no logs. ram only. [![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/) -[![NSA Approved](https://img.shields.io/badge/NSA-Hates_This-red.svg)](#)
--- -> ⚠️ **WARNING:** This tool was designed for absolute privacy. Once you close the terminal, the conversation never existed. Not even forensics can recover what wasn't written. Use responsibly. +peer-to-peer encrypted chat that runs in your terminal. you host, you control. close the window β€” everything's gone. ---- +## why -## πŸ’€ Why does this exist? +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. -Corporations sell your DMs. ISPs throttle your connections. "Encrypted" apps still store metadata on their servers. +## how it works -**CMD-CHAT is different.** +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SRP AUTHENTICATION β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ CLIENT SERVER β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ │─────── POST /srp/init {username, A} ───────► β”‚ β”‚ +β”‚ β”‚ (A = client public ephemeral) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ │◄────────── {user_id, B, salt} ────────────── β”‚ β”‚ +β”‚ β”‚ (B = server public ephemeral) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ [both sides compute shared 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] β”‚ | +β”‚ β”‚ β”‚ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ ENCRYPTED CHAT β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ β”‚ β”‚ +β”‚ │═══════ WebSocket /ws/chat?user_id ═════════► β”‚ β”‚ +β”‚ β”‚ (authenticated session) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ │◄═══════════ AES-encrypted messages ════════► β”‚ β”‚ +β”‚ β”‚ (Fernet = AES-128-CBC + HMAC) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ [on disconnect: keys wiped from RAM] β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` -It lives entirely in your RAM. It creates a secure tunnel between two terminals, encrypts every byte with military-grade algorithms, and then **self-destructs** the moment you type `/exit`. +**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. -### πŸ”₯ The "Paranoid" Feature Set - -- **Ghost Mode:** Nothing touches the hard drive. All data exists only in volatile memory (RAM). -- **Double-Blind Encryption:** RSA Handshake + AES Symmetric Session. Even if they intercept the traffic, it looks like white noise. -- **Kill Switch:** Closing the window wipes the keys instantly. -- **No Central Server:** You are the server. No logs for big tech to subpoena. - ---- - -## πŸ•΅οΈβ€β™‚οΈ How the "Black Box" Works - -1. **Handshake:** Client generates a disposable RSA key pair. -2. **Exchange:** Server mints a one-time symmetric key, encrypts it with your public key. -3. **Lock:** The tunnel is sealed. -4. **Vanish:** Communication happens. On exit, memory is overwritten. **Poof.** - ---- - -## ⚑ Quick Start (Before it gets banned) - -You don't need a PhD in cryptography. You just need Python. - -### 1. Grab the Code +## install ```bash git clone https://github.com/emilycodestar/cmd-chat.git cd cmd-chat +python -m venv venv && source venv/bin/activate && pip install -r requirements.txt ``` -### 2. Lock & Load (Install) +windows: ```bash -# Linux / macOS -python -m venv venv && source venv/bin/activate && pip install -r requirements.txt - -# Windows python -m venv venv ; .\venv\Scripts\activate ; pip install -r requirements.txt ``` -### 3. Go Dark (Usage) +## usage -#### Host the Bunker (Server) +start server: ```bash -python cmd_chat.py serve 0.0.0.0 3000 --password SUPER_SECRET_CODE +python cmd_chat.py serve 0.0.0.0 3000 --password mysecret ``` -#### Join the Channel (Client) +connect: ```bash -python cmd_chat.py connect SERVER_IP 3000 Tyler SUPER_SECRET_CODE +python cmd_chat.py connect SERVER_IP 3000 username mysecret ``` -(Replace SERVER_IP with localhost if testing locally) - -### πŸ‘οΈ Visual Proof - -They can't read this, but you can. - ![Example](example.gif) -
+## features -Built for the shadows. +- **ram only** β€” nothing touches disk +- **rsa + aes** β€” key exchange + symmetric encryption +- **no central server** β€” direct p2p connection +- **srp auth** β€” password never sent over network -If this repo disappears, you know why. πŸ˜‰ +## license -⭐ Star it while it's still up - -
+MIT diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index c7b38f3..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,47 +0,0 @@ -## πŸ—ΊοΈ Roadmap - -### 1. Core stability - -- [ ] Switch from `ast.literal_eval` to `json` for messages. -- [ ] Add proper reconnect with heartbeat (ping/pong, timeouts). -- [ ] Clean error handling (no leaking stack traces to clients). -- [ ] Limit message size and frequency (basic anti-spam). - -### 2. Security improvements - -- [ ] Per-client symmetric keys instead of one global key. -- [ ] Upgrade RSA 512 β†’ 2048 (or curve25519 ECDH + HKDF). -- [ ] Replace shared password with invite tokens or session-based bearer tokens. -- [ ] Force WSS (TLS) for production. - -### 3. Chat features - -- [ ] Multiple rooms (room_id support). -- [ ] Commands (`/nick`, `/clear`, `/help`, `/quit`). -- [ ] Message timestamps + sequence numbers. -- [ ] Delta updates (only send new messages instead of full history). - -### 4. UX / Client - -- [ ] Local encrypted history (optional). -- [ ] Customizable renderers (rich, minimal, json mode). -- [ ] Quiet reconnection status indicator. -- [ ] Configurable message buffer length. - -### 5. File & media - -- [ ] File transfer via encrypted chunks. -- [ ] Inline images (optional, in rich renderer). - -### 6. Deployment & Ops - -- [ ] Dockerfile + docker-compose (server + client). -- [ ] Add uvloop + multiple Sanic workers. -- [ ] Graceful shutdown & restart. -- [ ] Systemd service unit for server. - -### 7. Privacy & audit - -- [ ] Disable sensitive logs (no passwords/tokens in logs). -- [ ] Minimal server metrics: connected users, msg/sec. -- [ ] Configurable retention (in-memory only vs file-based). diff --git a/cmd_chat/__init__.py b/cmd_chat/__init__.py index 2d23216..c6ea4f6 100644 --- a/cmd_chat/__init__.py +++ b/cmd_chat/__init__.py @@ -3,10 +3,6 @@ from cmd_chat.server.server import run_server from cmd_chat.client.client import Client -def run_http_server(ip: str, port: int, password: str | None) -> None: - run_server(host=ip, port=int(port), admin_password=password) - - def main(): parser = argparse.ArgumentParser(description="Command-line chat application") subparsers = parser.add_subparsers(dest="command", required=True) @@ -25,7 +21,7 @@ def main(): args = parser.parse_args() if args.command == "serve": - run_http_server(args.ip_address, args.port, args.password) + run_server(host=args.ip_address, port=int(args.port), password=args.password) elif args.command == "connect": Client( server=args.ip_address, diff --git a/cmd_chat/client/client.py b/cmd_chat/client/client.py index 3febb65..d9c7a31 100644 --- a/cmd_chat/client/client.py +++ b/cmd_chat/client/client.py @@ -1,14 +1,17 @@ import asyncio import json +import base64 from typing import Optional -import rsa +import srp import requests from cryptography.fernet import Fernet import websockets from rich.console import Console from rich.panel import Panel +srp.rfc5054_enable() + class Client: def __init__( @@ -17,11 +20,8 @@ class Client: self.server = server self.port = port self.username = username - self.password = password or "" + self.password = (password or "").encode() self.user_id: Optional[str] = None - - self.public_key: Optional[rsa.PublicKey] = None - self.private_key: Optional[rsa.PrivateKey] = None self.fernet: Optional[Fernet] = None self.console = Console() @@ -47,34 +47,55 @@ class Client: def info(self, message: str) -> None: self.console.print(f"[cyan]β€’ {message}[/]") - def generate_keys(self) -> None: - with self.console.status( - "[cyan]Generating RSA keys (2048 bit)...[/]", spinner="dots" - ): - self.public_key, self.private_key = rsa.newkeys(2048) - self.success("RSA keys generated") + def srp_authenticate(self) -> None: + """SRP authentication flow""" + with self.console.status("[cyan]Starting SRP handshake...[/]", spinner="dots"): - def exchange_keys(self) -> None: - with self.console.status( - "[cyan]Exchanging keys with server...[/]", spinner="dots" - ): - pubkey_bytes = self.public_key.save_pkcs1() - response = requests.post( - f"{self.base_url}/get_key", - files={"pubkey": ("key.pem", pubkey_bytes)}, - data={"username": self.username, "password": self.password}, + usr = srp.User(b"chat", self.password, hash_alg=srp.SHA256) + _, A = usr.start_authentication() + + resp = requests.post( + f"{self.base_url}/srp/init", + json={ + "username": self.username, + "A": base64.b64encode(A).decode(), + }, timeout=30, ) - response.raise_for_status() + resp.raise_for_status() + init_data = resp.json() - self.user_id = response.headers.get("X-User-Id") - encrypted_key = response.content - symmetric_key = rsa.decrypt(encrypted_key, self.private_key) - self.fernet = Fernet(symmetric_key) + self.user_id = init_data["user_id"] + B = base64.b64decode(init_data["B"]) + salt = base64.b64decode(init_data["salt"]) - self.success(f"Key exchange complete (session: {self.user_id[:8]}...)") - self.public_key = None - self.private_key = None + M = usr.process_challenge(salt, B) + + if M is None: + raise ValueError("SRP challenge processing failed") + + resp = requests.post( + f"{self.base_url}/srp/verify", + json={ + "user_id": self.user_id, + "username": self.username, + "M": base64.b64encode(M).decode(), + }, + timeout=30, + ) + resp.raise_for_status() + verify_data = resp.json() + + H_AMK = base64.b64decode(verify_data["H_AMK"]) + usr.verify_session(H_AMK) + + if not usr.authenticated(): + raise ValueError("Server authentication failed") + + session_key = base64.b64decode(verify_data["session_key"]) + self.fernet = Fernet(session_key) + + self.success(f"SRP authenticated (session: {self.user_id[:8]}...)") def render_messages(self) -> None: self.console.clear() @@ -147,13 +168,10 @@ class Client: self.console.print() try: - self.generate_keys() - self.exchange_keys() + self.srp_authenticate() self.info("Connecting to chat...") - url = ( - f"{self.ws_url}/ws/chat?user_id={self.user_id}&password={self.password}" - ) + url = f"{self.ws_url}/ws/chat?user_id={self.user_id}" async with websockets.connect(url) as ws: self.success("Connected to chat server") @@ -175,10 +193,12 @@ class Client: self.error(f"Cannot connect to {self.base_url}") except requests.exceptions.HTTPError as e: self.error(f"Server error: {e.response.status_code} - {e.response.text}") - except Exception as e: + except ValueError as e: + self.error(f"Authentication failed: {e}") + except Exception: import traceback - self.error(f"Error: {e}") + self.error("Error occurred") traceback.print_exc() def run(self) -> None: diff --git a/cmd_chat/client/config.py b/cmd_chat/client/config.py index b5eeaf9..b36896c 100644 --- a/cmd_chat/client/config.py +++ b/cmd_chat/client/config.py @@ -1 +1 @@ -MESSAGES_TO_SHOW = 5 \ No newline at end of file +MESSAGES_TO_SHOW = 5 diff --git a/cmd_chat/client/core/abs/abs_crypto.py b/cmd_chat/client/core/abs/abs_crypto.py deleted file mode 100644 index 2b371e4..0000000 --- a/cmd_chat/client/core/abs/abs_crypto.py +++ /dev/null @@ -1,28 +0,0 @@ -from abc import ABC, abstractmethod - - -class CryptoService(ABC): - - @abstractmethod - def _encrypt(self, message: str) -> str: - raise NotImplementedError("Need to implement encrypt method") - - @abstractmethod - def _decrypt(self, message: str) -> str: - raise NotImplementedError("Need to implement decrypt method") - - @abstractmethod - def _request_key(self, url: str, username: str, password: str | None = None): - raise NotImplementedError("Need to implement request key method") - - @abstractmethod - def _generate_keys(self): - raise NotImplementedError("Need to implement generate keys method") - - @abstractmethod - def _get_generated_keys(self) -> tuple: - raise NotImplementedError("Need to implement get generated keys method") - - @abstractmethod - def _remove_keys(self): - raise NotImplementedError("Need to implement remove keys method") diff --git a/cmd_chat/client/core/abs/abs_renderer.py b/cmd_chat/client/core/abs/abs_renderer.py deleted file mode 100644 index dfd94b4..0000000 --- a/cmd_chat/client/core/abs/abs_renderer.py +++ /dev/null @@ -1,33 +0,0 @@ -from abc import ABC, abstractmethod - - -class ClientRenderer(ABC): - - username: str - - @abstractmethod - def _decrypt(self, message: str) -> str: - """Decrypt an encrypted message (provided by crypto mixin).""" - raise NotImplementedError("Need to implement _decrypt") - - @abstractmethod - def print_message(self, message: str) -> str: - raise NotImplementedError("Need to implement print_message") - - @abstractmethod - def clear_console(self) -> None: - """Clear the client console (platform-specific).""" - raise NotImplementedError("Need to implement clear_console") - - @abstractmethod - def print_ip(self, ip: str) -> str: - raise NotImplementedError("Need to implement print_ip") - - @abstractmethod - def print_username(self, username: str) -> str: - raise NotImplementedError("Need to implement print_username") - - @abstractmethod - def print_chat(self, response) -> None: - """Render chat payload (response is expected to be a mapping with 'messages' and 'users_in_chat').""" - raise NotImplementedError("Need to implement print_chat") diff --git a/cmd_chat/client/core/crypto.py b/cmd_chat/client/core/crypto.py deleted file mode 100644 index d599c52..0000000 --- a/cmd_chat/client/core/crypto.py +++ /dev/null @@ -1,43 +0,0 @@ -import rsa -import requests -from cryptography.fernet import Fernet - -from cmd_chat.client.core.abs.abs_crypto import CryptoService - - -class RSAService(CryptoService): - def __init__(self): - self.public_key = None - self.private_key = None - self.symmetric_key = None - self.fernet = None - self._generate_keys() - - def _encrypt(self, message: str) -> str: - return self.fernet.encrypt(message.encode()).decode("utf-8") - - def _decrypt(self, message: str) -> str: - return self.fernet.decrypt(message.encode()).decode("utf-8") - - def _request_key(self, url: str, username: str, password: str | None = None): - pubkey_bytes = self.public_key.save_pkcs1() - r = requests.post( - url, - files={"pubkey": ("public.pem", pubkey_bytes)}, - data={"username": username, "password": password or ""}, - stream=True, - ) - r.raise_for_status() - message = r.content - self.symmetric_key = rsa.decrypt(message, self.private_key) - self.fernet = Fernet(self.symmetric_key) - - def _generate_keys(self): - self.public_key, self.private_key = rsa.newkeys(2048) - - def _get_generated_keys(self): - return self.private_key, self.public_key - - def _remove_keys(self): - self.public_key = None - self.private_key = None diff --git a/cmd_chat/client/core/rich_renderer.py b/cmd_chat/client/core/rich_renderer.py deleted file mode 100644 index fecfb64..0000000 --- a/cmd_chat/client/core/rich_renderer.py +++ /dev/null @@ -1,72 +0,0 @@ -import os -import platform - -from rich.text import Text -from rich.style import Style -from rich.console import Console - -from rich.table import Table -from cmd_chat.client.core.abs.abs_renderer import ClientRenderer -from cmd_chat.client.config import MESSAGES_TO_SHOW - - -console = Console(width=75) - - -class RichClientRenderer(ClientRenderer): - - def __get_os(self) -> str: - """checking what kind of platform you need""" - if "Linux" in str(platform.platform()): - return "Linux" - return "Windows" - - def print_message(self, message: str) -> Text: - """generating string with message in required format""" - # split only on the first ':' so message bodies containing ':' are preserved - parts = message.split(":", 1) - if parts[0] == self.username: - return ( - Text(text=parts[0], style="bold") - + Text(text=": ", style="bold") - + Text(text=parts[1], style="underline") - ) - return ( - Text(text=parts[0], style="bold") - + Text(text=": ", style="bold") - + Text(text=parts[1], style="underline") - ) - - def clear_console(self): - # For windows clear command its cls - # For linux clear command its clear - if self.__get_os() == "Linux": - os.system("clear") - else: - os.system("cls") - - def print_ip(self, ip: str) -> str: - return ip - - def print_username(self, username: str) -> str: - return username - - def print_chat(self, response) -> None: - self.clear_console() - for i, msg in enumerate(response["messages"][-MESSAGES_TO_SHOW:]): - actual_message = self._decrypt(msg) - if i == 0: - console.print("Users in chat:", justify="left") - table = Table(show_header=True, header_style="bold magenta") - table.add_column("IP", style="dim", width=12) - table.add_column("USERNAME") - for user in response["users_in_chat"]: - table.add_row( - self.print_ip(user.split(",")[0]), - self.print_username(user.split(",")[1]), - ) - console.print(table) - console.print("Write 'q' to quit from chat", justify="left") - console.print(f"\n{self.print_message(actual_message)}") - else: - console.print(f"{self.print_message(actual_message)}") diff --git a/cmd_chat/server/factory.py b/cmd_chat/server/factory.py index ee2ce87..adb81dd 100644 --- a/cmd_chat/server/factory.py +++ b/cmd_chat/server/factory.py @@ -6,19 +6,20 @@ from sanic_ext import Extend from .managers import ConnectionManager from .stores import MessageStore, UserSessionStore +from .srp_auth import SRPAuthManager from .logger import logger from .routes import register_routes -def create_app() -> Sanic: - app = Sanic("cmd-chat-server") +def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic: + app = Sanic(name) Extend(app) app.ctx.message_store = MessageStore() app.ctx.session_store = UserSessionStore() app.ctx.connection_manager = ConnectionManager() - app.ctx.admin_password = None + app.ctx.srp_manager = SRPAuthManager(password) app.ctx.fernet_key = Fernet.generate_key() app.ctx.cleanup_task = None @@ -47,4 +48,4 @@ async def cleanup_stale_sessions(app: Sanic) -> None: while True: with suppress(asyncio.CancelledError): await asyncio.sleep(300) - await app.ctx.session_store.cleanup_stale() + app.ctx.session_store.cleanup_stale() diff --git a/cmd_chat/server/helpers.py b/cmd_chat/server/helpers.py index 506a88c..db47811 100644 --- a/cmd_chat/server/helpers.py +++ b/cmd_chat/server/helpers.py @@ -32,8 +32,8 @@ def require_auth(request: Request, app: Sanic) -> Optional[response.HTTPResponse async def send_state(ws: Websocket, app: Sanic) -> None: - messages = await app.ctx.message_store.get_all() - users = await app.ctx.session_store.get_all() + messages = app.ctx.message_store.get_all() + users = app.ctx.session_store.get_all() await ws.send( json.dumps( { diff --git a/cmd_chat/server/managers.py b/cmd_chat/server/managers.py index 670b0d5..a1c56dc 100644 --- a/cmd_chat/server/managers.py +++ b/cmd_chat/server/managers.py @@ -28,8 +28,8 @@ class ConnectionManager: continue try: await connection.send(message) - except Exception as e: - logger.warning(f"Failed to send message to {user_id}: {e}") + except Exception: + logger.exception(f"Failed to send message to {user_id}") disconnected.append(user_id) for user_id in disconnected: @@ -42,7 +42,7 @@ class ConnectionManager: try: await connection.send(message) return True - except Exception as e: - logger.warning(f"Failed to send personal message to {user_id}: {e}") + except Exception: + logger.exception(f"Failed to send personal message to {user_id}") return False return False diff --git a/cmd_chat/server/models.py b/cmd_chat/server/models.py index a61e771..dce1601 100644 --- a/cmd_chat/server/models.py +++ b/cmd_chat/server/models.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from uuid import uuid4 -from datetime import datetime +from datetime import datetime, timezone from typing import Optional @@ -8,7 +8,9 @@ from typing import Optional class Message: id: str = field(default_factory=lambda: str(uuid4())) text: str = "" - timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat()) + timestamp: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) user_ip: str = "" username: str = "" @@ -19,13 +21,17 @@ class UserSession: ip: str username: str = "unknown" fernet_key: Optional[bytes] = None - created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat()) - last_activity: str = field(default_factory=lambda: datetime.utcnow().isoformat()) + created_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + last_activity: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) active: bool = True def update_activity(self): - self.last_activity = datetime.utcnow().isoformat() + self.last_activity = datetime.now(timezone.utc).isoformat() def is_stale(self, timeout_seconds: int = 3600) -> bool: last = datetime.fromisoformat(self.last_activity) - return (datetime.utcnow() - last).total_seconds() > timeout_seconds + return (datetime.now(timezone.utc) - last).total_seconds() > timeout_seconds diff --git a/cmd_chat/server/routes.py b/cmd_chat/server/routes.py index 3c68256..6fee75b 100644 --- a/cmd_chat/server/routes.py +++ b/cmd_chat/server/routes.py @@ -4,9 +4,13 @@ from . import views def register_routes(app: Sanic) -> None: - @app.route("/get_key", methods=["GET", "POST"]) - async def get_key_route(request: Request): - return await views.get_key(request, app) + @app.post("/srp/init") + async def srp_init_route(request: Request): + return await views.srp_init(request, app) + + @app.post("/srp/verify") + async def srp_verify_route(request: Request): + return await views.srp_verify(request, app) @app.websocket("/ws/chat") async def chat_ws_route(request: Request, ws: Websocket): diff --git a/cmd_chat/server/server.py b/cmd_chat/server/server.py index 92bc83b..99df7fa 100644 --- a/cmd_chat/server/server.py +++ b/cmd_chat/server/server.py @@ -2,22 +2,20 @@ from typing import Optional from .logger import logger from .factory import create_app -app = create_app() - def run_server( host: str = "0.0.0.0", port: int = 8000, - admin_password: Optional[str] = None, + password: Optional[str] = None, workers: int = 1, ) -> None: - app.ctx.admin_password = admin_password + app = create_app(password=password or "") logger.info(f"Starting server on {host}:{port}") app.run( host=host, port=port, - workers=workers, + single_process=True, debug=False, access_log=True, ) diff --git a/cmd_chat/server/srp_auth.py b/cmd_chat/server/srp_auth.py new file mode 100644 index 0000000..98ccb03 --- /dev/null +++ b/cmd_chat/server/srp_auth.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass, field +from typing import Optional +from uuid import uuid4 + +import srp + + +srp.rfc5054_enable() + + +@dataclass +class SRPSession: + user_id: str = field(default_factory=lambda: str(uuid4())) + username: str = "" + svr: Optional[srp.Verifier] = None + session_key: Optional[bytes] = None + authenticated: bool = False + + +class SRPAuthManager: + def __init__(self, password: str): + self.password = password.encode() + self.sessions: dict[str, SRPSession] = {} + self.salt, self.vkey = srp.create_salted_verification_key( + b"chat", self.password, hash_alg=srp.SHA256 + ) + + def init_auth( + self, username: str, client_public: bytes + ) -> tuple[str, bytes, bytes]: + session = SRPSession(username=username) + + svr = srp.Verifier( + b"chat", self.salt, self.vkey, client_public, hash_alg=srp.SHA256 + ) + + s, B = svr.get_challenge() + + if B is None: + raise ValueError("SRP challenge generation failed") + + session.svr = svr + self.sessions[session.user_id] = session + + return session.user_id, B, s + + def verify_auth(self, user_id: str, client_proof: bytes) -> tuple[bytes, bytes]: + session = self.sessions.get(user_id) + if not session or not session.svr: + raise ValueError("Invalid session") + + H_AMK = session.svr.verify_session(client_proof) + + if H_AMK is None: + del self.sessions[user_id] + raise ValueError("Authentication failed") + + session.session_key = session.svr.get_session_key() + session.authenticated = True + + return H_AMK, session.session_key + + def get_session(self, user_id: str) -> Optional[SRPSession]: + session = self.sessions.get(user_id) + if session and session.authenticated: + return session + return None + + def remove_session(self, user_id: str) -> None: + self.sessions.pop(user_id, None) diff --git a/cmd_chat/server/stores.py b/cmd_chat/server/stores.py index 50b5af7..2b19949 100644 --- a/cmd_chat/server/stores.py +++ b/cmd_chat/server/stores.py @@ -1,6 +1,4 @@ -import asyncio from typing import Optional - from .models import Message, UserSession from .logger import logger @@ -8,72 +6,58 @@ from .logger import logger class MessageStore: def __init__(self): self._messages: list[Message] = [] - self._lock = asyncio.Lock() - async def add(self, message: Message) -> None: - async with self._lock: - self._messages.append(message) - logger.info(f"Message added: {message.id} from {message.username}") + def add(self, message: Message) -> None: + self._messages.append(message) + logger.info(f"Message added: {message.id} from {message.username}") - async def get_all(self) -> list[Message]: - async with self._lock: - return self._messages.copy() + def get_all(self) -> list[Message]: + return self._messages.copy() - async def clear(self) -> None: - async with self._lock: - count = len(self._messages) - self._messages.clear() - logger.info(f"Cleared {count} messages") + def clear(self) -> None: + count = len(self._messages) + self._messages.clear() + logger.info(f"Cleared {count} messages") - async def count(self) -> int: - async with self._lock: - return len(self._messages) + def count(self) -> int: + return len(self._messages) class UserSessionStore: def __init__(self): self._sessions: dict[str, UserSession] = {} - self._lock = asyncio.Lock() - async def add(self, session: UserSession) -> None: - async with self._lock: - self._sessions[session.user_id] = session - logger.info(f"Session created: {session.user_id} ({session.username})") + def add(self, session: UserSession) -> None: + self._sessions[session.user_id] = session + logger.info(f"Session created: {session.user_id} ({session.username})") - async def get(self, user_id: str) -> Optional[UserSession]: - async with self._lock: - return self._sessions.get(user_id) + def get(self, user_id: str) -> Optional[UserSession]: + return self._sessions.get(user_id) - async def update_activity(self, user_id: str) -> None: - async with self._lock: - if session := self._sessions.get(user_id): - session.update_activity() + def update_activity(self, user_id: str) -> None: + if session := self._sessions.get(user_id): + session.update_activity() - async def remove(self, user_id: str) -> None: - async with self._lock: - if user_id in self._sessions: - del self._sessions[user_id] - logger.info(f"Session removed: {user_id}") + def remove(self, user_id: str) -> None: + if user_id in self._sessions: + del self._sessions[user_id] + logger.info(f"Session removed: {user_id}") - async def cleanup_stale(self, timeout_seconds: int = 3600) -> int: - async with self._lock: - stale_ids = [ - uid for uid, s in self._sessions.items() if s.is_stale(timeout_seconds) - ] - for uid in stale_ids: - del self._sessions[uid] - if stale_ids: - logger.info(f"Cleaned up {len(stale_ids)} stale sessions") - return len(stale_ids) + def cleanup_stale(self, timeout_seconds: int = 3600) -> int: + stale_ids = [ + uid for uid, s in self._sessions.items() if s.is_stale(timeout_seconds) + ] + for uid in stale_ids: + del self._sessions[uid] + if stale_ids: + logger.info(f"Cleaned up {len(stale_ids)} stale sessions") + return len(stale_ids) - async def get_all(self) -> list[UserSession]: - async with self._lock: - return list(self._sessions.values()) + def get_all(self) -> list[UserSession]: + return list(self._sessions.values()) - async def count(self) -> int: - async with self._lock: - return len(self._sessions) + def count(self) -> int: + return len(self._sessions) - async def username_exists(self, username: str) -> bool: - async with self._lock: - return any(s.username == username for s in self._sessions.values()) + def username_exists(self, username: str) -> bool: + return any(s.username == username for s in self._sessions.values()) diff --git a/cmd_chat/server/views.py b/cmd_chat/server/views.py index 50f60cc..7d26b3e 100644 --- a/cmd_chat/server/views.py +++ b/cmd_chat/server/views.py @@ -1,65 +1,94 @@ from dataclasses import asdict from uuid import uuid4 import json +import base64 -import rsa from sanic import Sanic, Request, response, Websocket from sanic.response import HTTPResponse, json as json_response +from cryptography.fernet import Fernet from .models import Message, UserSession from .logger import logger from .helpers import ( - require_auth, - extract_pubkey, get_client_ip, get_param, - verify_password, send_state, utcnow, ) -async def get_key(request: Request, app: Sanic) -> HTTPResponse: - if err := require_auth(request, app): - return err - - pubkey_bytes = extract_pubkey(request) - if not pubkey_bytes: - return response.text("Bad request: pubkey is required", status=400) - +async def srp_init(request: Request, app: Sanic) -> HTTPResponse: + """SRP Step 1: ΠΊΠ»ΠΈΠ΅Π½Ρ‚ отправляСт username + A""" try: - public_key = rsa.PublicKey.load_pkcs1(pubkey_bytes) - if public_key.n.bit_length() < 2048: - raise ValueError("RSA key must be at least 2048 bits") - except Exception as e: - logger.warning(f"Invalid public key: {e}") - return response.text(f"Bad pubkey: {e}", status=400) + data = request.json or {} + username = data.get("username", "unknown") + client_public_b64 = data.get("A") - username = get_param(request, "username") or "unknown" + if not client_public_b64: + return response.json({"error": "Missing A"}, status=400) - if await app.ctx.session_store.username_exists(username): - return response.text(f"Username '{username}' is already taken", status=409) + client_public = base64.b64decode(client_public_b64) - session = UserSession( - user_id=str(uuid4()), - ip=get_client_ip(request), - username=get_param(request, "username") or "unknown", - fernet_key=app.ctx.fernet_key, - ) - await app.ctx.session_store.add(session) + if app.ctx.session_store.username_exists(username): + return response.json({"error": "Username taken"}, status=409) - try: - encrypted_key = rsa.encrypt(app.ctx.fernet_key, public_key) - logger.info(f"Key exchange: user={session.username}, session={session.user_id}") + user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public) - return response.raw( - encrypted_key, - content_type="application/octet-stream", - headers={"X-User-Id": session.user_id}, + logger.info(f"SRP init: {username} ({user_id[:8]}...)") + + return response.json( + { + "user_id": user_id, + "B": base64.b64encode(B).decode(), + "salt": base64.b64encode(salt).decode(), + } ) - except Exception as e: - logger.error(f"Encryption failed: {e}") - return response.text("Key encryption failed", status=500) + + except Exception: + logger.exception("SRP init failed") + return response.json({"error": "SRP init failed"}, status=500) + + +async def srp_verify(request: Request, app: Sanic) -> HTTPResponse: + """SRP Step 2: ΠΊΠ»ΠΈΠ΅Π½Ρ‚ отправляСт proof M""" + try: + data = request.json or {} + user_id = data.get("user_id") + client_proof_b64 = data.get("M") + username = data.get("username", "unknown") + + if not user_id or not client_proof_b64: + return response.json({"error": "Missing user_id or M"}, status=400) + + client_proof = base64.b64decode(client_proof_b64) + + H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof) + + fernet_key = base64.urlsafe_b64encode(session_key[:32]) + + session = UserSession( + user_id=user_id, + ip=get_client_ip(request), + username=username, + fernet_key=fernet_key, + ) + app.ctx.session_store.add(session) + + logger.info(f"SRP verified: {username} ({user_id[:8]}...)") + + return response.json( + { + "H_AMK": base64.b64encode(H_AMK).decode(), + "session_key": base64.b64encode(fernet_key).decode(), + } + ) + + except ValueError as e: + logger.warning(f"SRP verify failed: {e}") + return response.json({"error": str(e)}, status=401) + except Exception: + logger.exception("SRP verify failed") + return response.json({"error": "SRP verify failed"}, status=500) async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: @@ -69,17 +98,13 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: await ws.close(code=4002, reason="user_id required") return - if not verify_password(request.args.get("password"), app.ctx.admin_password): - await ws.close(code=4001, reason="Unauthorized") - return - - session = await app.ctx.session_store.get(user_id) + session = app.ctx.session_store.get(user_id) if not session: await ws.close(code=4002, reason="Invalid session") return manager = app.ctx.connection_manager - await manager.connect(user_id, ws) + await manager.connect(user_id, ws) # await Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ try: await send_state(ws, app) @@ -88,14 +113,14 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: if data is None: break - await app.ctx.session_store.update_activity(user_id) + app.ctx.session_store.update_activity(user_id) message = Message( text=str(data), user_ip=session.ip, username=session.username, ) - await app.ctx.message_store.add(message) + app.ctx.message_store.add(message) await manager.broadcast( json.dumps( @@ -106,10 +131,10 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None: ) ) - except Exception as e: - logger.error(f"WebSocket error for {user_id}: {e}") + except Exception: + logger.exception(f"WebSocket error for {user_id}") finally: - await manager.disconnect(user_id) + await manager.disconnect(user_id) # await Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ await manager.broadcast( json.dumps( { @@ -124,15 +149,17 @@ async def health(request: Request, app: Sanic) -> HTTPResponse: return json_response( { "status": "ok", - "messages": await app.ctx.message_store.count(), - "users": await app.ctx.session_store.count(), + "messages": app.ctx.message_store.count(), + "users": app.ctx.session_store.count(), "timestamp": utcnow().isoformat(), } ) async def clear_messages(request: Request, app: Sanic) -> HTTPResponse: - if err := require_auth(request, app): - return err - await app.ctx.message_store.clear() + user_id = request.args.get("user_id") + if not user_id or not app.ctx.session_store.get(user_id): + return response.json({"error": "Unauthorized"}, status=401) + + app.ctx.message_store.clear() return json_response({"status": "cleared"}) diff --git a/requirements.txt b/requirements.txt index d0283a2..39a62e3 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/setup.py b/setup.py deleted file mode 100644 index b30a158..0000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -import setuptools - -with open("README.md", "r", encoding="utf-8") as fh: - description = fh.read() - -setuptools.setup( - name="secured_console_chat", - version="1.1.22", - author="dinosaurtirex", - author_email="sneakybeaky18@gmail.com", - # Use find_packages to correctly discover package names - packages=setuptools.find_packages(exclude=("tests", "docs")), - description="Secured console chat with RSA & Fernet", - long_description=description, - long_description_content_type="text/markdown", - url="https://github.com/dinosaurtirex/cmd-chat", - license="MIT", - python_requires=">=3.10", - entry_points={"console_scripts": ["cmd_chat = cmd_chat:main"]}, - install_requires=[ - "sanic", - "requests", - "rsa", - "cryptography", - "colorama", - "pydantic", - "websocket-client", - ], -) diff --git a/tests.bat b/tests.bat new file mode 100644 index 0000000..a02c1a5 --- /dev/null +++ b/tests.bat @@ -0,0 +1 @@ +pytest tests/ -v \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9e8ae82 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,41 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import uuid +import pytest +from sanic_testing import TestManager +from sanic import Sanic +from sanic_ext import Extend +from cryptography.fernet import Fernet + +from cmd_chat.server.managers import ConnectionManager +from cmd_chat.server.stores import MessageStore, UserSessionStore +from cmd_chat.server.srp_auth import SRPAuthManager +from cmd_chat.server.routes import register_routes + + +@pytest.fixture +def app(): + name = f"test-{uuid.uuid4().hex[:8]}" + + app = Sanic(name) + Extend(app) + + app.ctx.message_store = MessageStore() + app.ctx.session_store = UserSessionStore() + app.ctx.connection_manager = ConnectionManager() + app.ctx.srp_manager = SRPAuthManager("testpassword") + app.ctx.fernet_key = Fernet.generate_key() + app.ctx.cleanup_task = None + + register_routes(app) + TestManager(app) + + return app + + +@pytest.fixture +def test_client(app): + return app.test_client \ No newline at end of file diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..a6752c0 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,17 @@ +# tests/test_health.py +import pytest + + +class TestHealth: + """ВСсты health endpoint""" + + def test_health_ok(self, test_client): + """GET /health Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ статус""" + _, response = test_client.get("/health") + + assert response.status == 200 + data = response.json + assert data["status"] == "ok" + assert "messages" in data + assert "users" in data + assert "timestamp" in data \ No newline at end of file diff --git a/tests/test_srp.py b/tests/test_srp.py new file mode 100644 index 0000000..b6d16e4 --- /dev/null +++ b/tests/test_srp.py @@ -0,0 +1,48 @@ +# tests/test_srp.py +import base64 +import pytest +import srp + + +class TestSRPFlow: + """ВСсты SRP Π°ΡƒΡ‚Π΅Π½Ρ‚ΠΈΡ„ΠΈΠΊΠ°Ρ†ΠΈΠΈ""" + + def test_srp_init_success(self, test_client): + """POST /srp/init Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ user_id, B, salt""" + usr = srp.User(b"chat", b"testpassword") + _, A = usr.start_authentication() + + _, response = test_client.post( + "/srp/init", + json={ + "username": "testuser", + "A": base64.b64encode(A).decode(), + }, + ) + + assert response.status == 200 + data = response.json + assert "user_id" in data + assert "B" in data + assert "salt" in data + + def test_srp_init_missing_a(self, test_client): + """POST /srp/init Π±Π΅Π· A Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ 400""" + _, response = test_client.post( + "/srp/init", + json={"username": "testuser"}, + ) + + assert response.status == 400 + + def test_srp_verify_invalid_session(self, test_client): + """Verify с Π½Π΅ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΌ user_id Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ 401""" + _, response = test_client.post( + "/srp/verify", + json={ + "user_id": "nonexistent", + "username": "testuser", + "M": base64.b64encode(b"fake").decode(), + }, + ) + assert response.status == 401 \ No newline at end of file diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..2b3d902 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,17 @@ +# tests/test_websocket.py +import pytest + + +class TestWebSocket: + """ВСсты WebSocket ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ""" + + def test_ws_connect_no_user_id(self, test_client): + """WebSocket Π±Π΅Π· user_id отклоняСтся""" + _, ws = test_client.websocket("/ws/chat") + # ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ Ρ‡Ρ‚ΠΎ соСдинСниС Π·Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ ΠΈΠ»ΠΈ Π²Π΅Ρ€Π½ΡƒΠ»Π°ΡΡŒ ошибка + assert ws is not None + + def test_ws_connect_invalid_session(self, test_client): + """WebSocket с Π½Π΅Π²Π°Π»ΠΈΠ΄Π½Ρ‹ΠΌ user_id отклоняСтся""" + _, ws = test_client.websocket("/ws/chat?user_id=invalid123") + assert ws is not None \ No newline at end of file