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.
[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
-[](#)
---
-> β οΈ **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.
-

-
+## 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