feat: add SRP authentication, improve security

- Replace RSA key exchange with SRP (Secure Remote Password)
- Password never transmitted over network
- Add unit tests for endpoints
- Fix datetime.UTC compatibility for Python < 3.11
- Fix logger.exception usage
- Update README with new auth flow diagram
This commit is contained in:
mirai 2026-01-02 23:09:00 +03:00
parent e3a3dd3f0f
commit 5cbe355660
26 changed files with 470 additions and 482 deletions

2
.gitignore vendored
View File

@ -12,4 +12,6 @@ __pycache__
cmd_chat.egg-info cmd_chat.egg-info
build build
dist dist
true.txt
secured_console_chat.egg-info secured_console_chat.egg-info
.pytest_cache/

120
README.MD
View File

@ -1,98 +1,106 @@
<div align="center"> <div align="center">
# 🤐 CMD-CHAT: The "Ghost" Protocol # 🤐 CMD-CHAT
### The chat tool your ISP doesn't want you to have. ### encrypted terminal chat. no servers. no logs. ram only.
### Zero Logs. Zero Disk Writes. 100% Deniable.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![Python 3.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)](#)
</div> </div>
--- ---
> ⚠️ **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 ## install
- **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
```bash ```bash
git clone https://github.com/emilycodestar/cmd-chat.git git clone https://github.com/emilycodestar/cmd-chat.git
cd cmd-chat cd cmd-chat
python -m venv venv && source venv/bin/activate && pip install -r requirements.txt
``` ```
### 2. Lock & Load (Install) windows:
```bash ```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 python -m venv venv ; .\venv\Scripts\activate ; pip install -r requirements.txt
``` ```
### 3. Go Dark (Usage) ## usage
#### Host the Bunker (Server) start server:
```bash ```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 ```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) ![Example](example.gif)
<div align="center"> ## 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
</div>

View File

@ -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).

View File

@ -3,10 +3,6 @@ from cmd_chat.server.server import run_server
from cmd_chat.client.client import Client 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(): def main():
parser = argparse.ArgumentParser(description="Command-line chat application") parser = argparse.ArgumentParser(description="Command-line chat application")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
@ -25,7 +21,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
if args.command == "serve": 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": elif args.command == "connect":
Client( Client(
server=args.ip_address, server=args.ip_address,

View File

@ -1,14 +1,17 @@
import asyncio import asyncio
import json import json
import base64
from typing import Optional from typing import Optional
import rsa import srp
import requests import requests
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
import websockets import websockets
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
srp.rfc5054_enable()
class Client: class Client:
def __init__( def __init__(
@ -17,11 +20,8 @@ class Client:
self.server = server self.server = server
self.port = port self.port = port
self.username = username self.username = username
self.password = password or "" self.password = (password or "").encode()
self.user_id: Optional[str] = None 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.fernet: Optional[Fernet] = None
self.console = Console() self.console = Console()
@ -47,34 +47,55 @@ class Client:
def info(self, message: str) -> None: def info(self, message: str) -> None:
self.console.print(f"[cyan]• {message}[/]") self.console.print(f"[cyan]• {message}[/]")
def generate_keys(self) -> None: def srp_authenticate(self) -> None:
with self.console.status( """SRP authentication flow"""
"[cyan]Generating RSA keys (2048 bit)...[/]", spinner="dots" with self.console.status("[cyan]Starting SRP handshake...[/]", spinner="dots"):
):
self.public_key, self.private_key = rsa.newkeys(2048)
self.success("RSA keys generated")
def exchange_keys(self) -> None: usr = srp.User(b"chat", self.password, hash_alg=srp.SHA256)
with self.console.status( _, A = usr.start_authentication()
"[cyan]Exchanging keys with server...[/]", spinner="dots"
): resp = requests.post(
pubkey_bytes = self.public_key.save_pkcs1() f"{self.base_url}/srp/init",
response = requests.post( json={
f"{self.base_url}/get_key", "username": self.username,
files={"pubkey": ("key.pem", pubkey_bytes)}, "A": base64.b64encode(A).decode(),
data={"username": self.username, "password": self.password}, },
timeout=30, timeout=30,
) )
response.raise_for_status() resp.raise_for_status()
init_data = resp.json()
self.user_id = response.headers.get("X-User-Id") self.user_id = init_data["user_id"]
encrypted_key = response.content B = base64.b64decode(init_data["B"])
symmetric_key = rsa.decrypt(encrypted_key, self.private_key) salt = base64.b64decode(init_data["salt"])
self.fernet = Fernet(symmetric_key)
self.success(f"Key exchange complete (session: {self.user_id[:8]}...)") M = usr.process_challenge(salt, B)
self.public_key = None
self.private_key = None 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: def render_messages(self) -> None:
self.console.clear() self.console.clear()
@ -147,13 +168,10 @@ class Client:
self.console.print() self.console.print()
try: try:
self.generate_keys() self.srp_authenticate()
self.exchange_keys()
self.info("Connecting to chat...") self.info("Connecting to chat...")
url = ( url = f"{self.ws_url}/ws/chat?user_id={self.user_id}"
f"{self.ws_url}/ws/chat?user_id={self.user_id}&password={self.password}"
)
async with websockets.connect(url) as ws: async with websockets.connect(url) as ws:
self.success("Connected to chat server") self.success("Connected to chat server")
@ -175,10 +193,12 @@ class Client:
self.error(f"Cannot connect to {self.base_url}") self.error(f"Cannot connect to {self.base_url}")
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.error(f"Server error: {e.response.status_code} - {e.response.text}") 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 import traceback
self.error(f"Error: {e}") self.error("Error occurred")
traceback.print_exc() traceback.print_exc()
def run(self) -> None: def run(self) -> None:

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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)}")

View File

@ -6,19 +6,20 @@ from sanic_ext import Extend
from .managers import ConnectionManager from .managers import ConnectionManager
from .stores import MessageStore, UserSessionStore from .stores import MessageStore, UserSessionStore
from .srp_auth import SRPAuthManager
from .logger import logger from .logger import logger
from .routes import register_routes from .routes import register_routes
def create_app() -> Sanic: def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic:
app = Sanic("cmd-chat-server") app = Sanic(name)
Extend(app) Extend(app)
app.ctx.message_store = MessageStore() app.ctx.message_store = MessageStore()
app.ctx.session_store = UserSessionStore() app.ctx.session_store = UserSessionStore()
app.ctx.connection_manager = ConnectionManager() 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.fernet_key = Fernet.generate_key()
app.ctx.cleanup_task = None app.ctx.cleanup_task = None
@ -47,4 +48,4 @@ async def cleanup_stale_sessions(app: Sanic) -> None:
while True: while True:
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await asyncio.sleep(300) await asyncio.sleep(300)
await app.ctx.session_store.cleanup_stale() app.ctx.session_store.cleanup_stale()

View File

@ -32,8 +32,8 @@ def require_auth(request: Request, app: Sanic) -> Optional[response.HTTPResponse
async def send_state(ws: Websocket, app: Sanic) -> None: async def send_state(ws: Websocket, app: Sanic) -> None:
messages = await app.ctx.message_store.get_all() messages = app.ctx.message_store.get_all()
users = await app.ctx.session_store.get_all() users = app.ctx.session_store.get_all()
await ws.send( await ws.send(
json.dumps( json.dumps(
{ {

View File

@ -28,8 +28,8 @@ class ConnectionManager:
continue continue
try: try:
await connection.send(message) await connection.send(message)
except Exception as e: except Exception:
logger.warning(f"Failed to send message to {user_id}: {e}") logger.exception(f"Failed to send message to {user_id}")
disconnected.append(user_id) disconnected.append(user_id)
for user_id in disconnected: for user_id in disconnected:
@ -42,7 +42,7 @@ class ConnectionManager:
try: try:
await connection.send(message) await connection.send(message)
return True return True
except Exception as e: except Exception:
logger.warning(f"Failed to send personal message to {user_id}: {e}") logger.exception(f"Failed to send personal message to {user_id}")
return False return False
return False return False

View File

@ -1,6 +1,6 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from uuid import uuid4 from uuid import uuid4
from datetime import datetime from datetime import datetime, timezone
from typing import Optional from typing import Optional
@ -8,7 +8,9 @@ from typing import Optional
class Message: class Message:
id: str = field(default_factory=lambda: str(uuid4())) id: str = field(default_factory=lambda: str(uuid4()))
text: str = "" 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 = "" user_ip: str = ""
username: str = "" username: str = ""
@ -19,13 +21,17 @@ class UserSession:
ip: str ip: str
username: str = "unknown" username: str = "unknown"
fernet_key: Optional[bytes] = None fernet_key: Optional[bytes] = None
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat()) created_at: str = field(
last_activity: str = field(default_factory=lambda: datetime.utcnow().isoformat()) default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
last_activity: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
active: bool = True active: bool = True
def update_activity(self): 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: def is_stale(self, timeout_seconds: int = 3600) -> bool:
last = datetime.fromisoformat(self.last_activity) last = datetime.fromisoformat(self.last_activity)
return (datetime.utcnow() - last).total_seconds() > timeout_seconds return (datetime.now(timezone.utc) - last).total_seconds() > timeout_seconds

View File

@ -4,9 +4,13 @@ from . import views
def register_routes(app: Sanic) -> None: def register_routes(app: Sanic) -> None:
@app.route("/get_key", methods=["GET", "POST"]) @app.post("/srp/init")
async def get_key_route(request: Request): async def srp_init_route(request: Request):
return await views.get_key(request, app) 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") @app.websocket("/ws/chat")
async def chat_ws_route(request: Request, ws: Websocket): async def chat_ws_route(request: Request, ws: Websocket):

View File

@ -2,22 +2,20 @@ from typing import Optional
from .logger import logger from .logger import logger
from .factory import create_app from .factory import create_app
app = create_app()
def run_server( def run_server(
host: str = "0.0.0.0", host: str = "0.0.0.0",
port: int = 8000, port: int = 8000,
admin_password: Optional[str] = None, password: Optional[str] = None,
workers: int = 1, workers: int = 1,
) -> None: ) -> None:
app.ctx.admin_password = admin_password app = create_app(password=password or "")
logger.info(f"Starting server on {host}:{port}") logger.info(f"Starting server on {host}:{port}")
app.run( app.run(
host=host, host=host,
port=port, port=port,
workers=workers, single_process=True,
debug=False, debug=False,
access_log=True, access_log=True,
) )

View File

@ -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)

View File

@ -1,6 +1,4 @@
import asyncio
from typing import Optional from typing import Optional
from .models import Message, UserSession from .models import Message, UserSession
from .logger import logger from .logger import logger
@ -8,72 +6,58 @@ from .logger import logger
class MessageStore: class MessageStore:
def __init__(self): def __init__(self):
self._messages: list[Message] = [] self._messages: list[Message] = []
self._lock = asyncio.Lock()
async def add(self, message: Message) -> None: def add(self, message: Message) -> None:
async with self._lock: self._messages.append(message)
self._messages.append(message) logger.info(f"Message added: {message.id} from {message.username}")
logger.info(f"Message added: {message.id} from {message.username}")
async def get_all(self) -> list[Message]: def get_all(self) -> list[Message]:
async with self._lock: return self._messages.copy()
return self._messages.copy()
async def clear(self) -> None: def clear(self) -> None:
async with self._lock: count = len(self._messages)
count = len(self._messages) self._messages.clear()
self._messages.clear() logger.info(f"Cleared {count} messages")
logger.info(f"Cleared {count} messages")
async def count(self) -> int: def count(self) -> int:
async with self._lock: return len(self._messages)
return len(self._messages)
class UserSessionStore: class UserSessionStore:
def __init__(self): def __init__(self):
self._sessions: dict[str, UserSession] = {} self._sessions: dict[str, UserSession] = {}
self._lock = asyncio.Lock()
async def add(self, session: UserSession) -> None: def add(self, session: UserSession) -> None:
async with self._lock: self._sessions[session.user_id] = session
self._sessions[session.user_id] = session logger.info(f"Session created: {session.user_id} ({session.username})")
logger.info(f"Session created: {session.user_id} ({session.username})")
async def get(self, user_id: str) -> Optional[UserSession]: def get(self, user_id: str) -> Optional[UserSession]:
async with self._lock: return self._sessions.get(user_id)
return self._sessions.get(user_id)
async def update_activity(self, user_id: str) -> None: def update_activity(self, user_id: str) -> None:
async with self._lock: if session := self._sessions.get(user_id):
if session := self._sessions.get(user_id): session.update_activity()
session.update_activity()
async def remove(self, user_id: str) -> None: def remove(self, user_id: str) -> None:
async with self._lock: if user_id in self._sessions:
if user_id in self._sessions: del self._sessions[user_id]
del self._sessions[user_id] logger.info(f"Session removed: {user_id}")
logger.info(f"Session removed: {user_id}")
async def cleanup_stale(self, timeout_seconds: int = 3600) -> int: def cleanup_stale(self, timeout_seconds: int = 3600) -> int:
async with self._lock: stale_ids = [
stale_ids = [ uid for uid, s in self._sessions.items() if s.is_stale(timeout_seconds)
uid for uid, s in self._sessions.items() if s.is_stale(timeout_seconds) ]
] for uid in stale_ids:
for uid in stale_ids: del self._sessions[uid]
del self._sessions[uid] if stale_ids:
if stale_ids: logger.info(f"Cleaned up {len(stale_ids)} stale sessions")
logger.info(f"Cleaned up {len(stale_ids)} stale sessions") return len(stale_ids)
return len(stale_ids)
async def get_all(self) -> list[UserSession]: def get_all(self) -> list[UserSession]:
async with self._lock: return list(self._sessions.values())
return list(self._sessions.values())
async def count(self) -> int: def count(self) -> int:
async with self._lock: return len(self._sessions)
return len(self._sessions)
async def username_exists(self, username: str) -> bool: def username_exists(self, username: str) -> bool:
async with self._lock: return any(s.username == username for s in self._sessions.values())
return any(s.username == username for s in self._sessions.values())

View File

@ -1,65 +1,94 @@
from dataclasses import asdict from dataclasses import asdict
from uuid import uuid4 from uuid import uuid4
import json import json
import base64
import rsa
from sanic import Sanic, Request, response, Websocket from sanic import Sanic, Request, response, Websocket
from sanic.response import HTTPResponse, json as json_response from sanic.response import HTTPResponse, json as json_response
from cryptography.fernet import Fernet
from .models import Message, UserSession from .models import Message, UserSession
from .logger import logger from .logger import logger
from .helpers import ( from .helpers import (
require_auth,
extract_pubkey,
get_client_ip, get_client_ip,
get_param, get_param,
verify_password,
send_state, send_state,
utcnow, utcnow,
) )
async def get_key(request: Request, app: Sanic) -> HTTPResponse: async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
if err := require_auth(request, app): """SRP Step 1: клиент отправляет username + A"""
return err
pubkey_bytes = extract_pubkey(request)
if not pubkey_bytes:
return response.text("Bad request: pubkey is required", status=400)
try: try:
public_key = rsa.PublicKey.load_pkcs1(pubkey_bytes) data = request.json or {}
if public_key.n.bit_length() < 2048: username = data.get("username", "unknown")
raise ValueError("RSA key must be at least 2048 bits") client_public_b64 = data.get("A")
except Exception as e:
logger.warning(f"Invalid public key: {e}")
return response.text(f"Bad pubkey: {e}", status=400)
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): client_public = base64.b64decode(client_public_b64)
return response.text(f"Username '{username}' is already taken", status=409)
session = UserSession( if app.ctx.session_store.username_exists(username):
user_id=str(uuid4()), return response.json({"error": "Username taken"}, status=409)
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)
try: user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public)
encrypted_key = rsa.encrypt(app.ctx.fernet_key, public_key)
logger.info(f"Key exchange: user={session.username}, session={session.user_id}")
return response.raw( logger.info(f"SRP init: {username} ({user_id[:8]}...)")
encrypted_key,
content_type="application/octet-stream", return response.json(
headers={"X-User-Id": session.user_id}, {
"user_id": user_id,
"B": base64.b64encode(B).decode(),
"salt": base64.b64encode(salt).decode(),
}
) )
except Exception as e:
logger.error(f"Encryption failed: {e}") except Exception:
return response.text("Key encryption failed", status=500) 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: 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") await ws.close(code=4002, reason="user_id required")
return return
if not verify_password(request.args.get("password"), app.ctx.admin_password): session = app.ctx.session_store.get(user_id)
await ws.close(code=4001, reason="Unauthorized")
return
session = await app.ctx.session_store.get(user_id)
if not session: if not session:
await ws.close(code=4002, reason="Invalid session") await ws.close(code=4002, reason="Invalid session")
return return
manager = app.ctx.connection_manager manager = app.ctx.connection_manager
await manager.connect(user_id, ws) await manager.connect(user_id, ws) # await добавлен
try: try:
await send_state(ws, app) await send_state(ws, app)
@ -88,14 +113,14 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
if data is None: if data is None:
break break
await app.ctx.session_store.update_activity(user_id) app.ctx.session_store.update_activity(user_id)
message = Message( message = Message(
text=str(data), text=str(data),
user_ip=session.ip, user_ip=session.ip,
username=session.username, username=session.username,
) )
await app.ctx.message_store.add(message) app.ctx.message_store.add(message)
await manager.broadcast( await manager.broadcast(
json.dumps( json.dumps(
@ -106,10 +131,10 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
) )
) )
except Exception as e: except Exception:
logger.error(f"WebSocket error for {user_id}: {e}") logger.exception(f"WebSocket error for {user_id}")
finally: finally:
await manager.disconnect(user_id) await manager.disconnect(user_id) # await добавлен
await manager.broadcast( await manager.broadcast(
json.dumps( json.dumps(
{ {
@ -124,15 +149,17 @@ async def health(request: Request, app: Sanic) -> HTTPResponse:
return json_response( return json_response(
{ {
"status": "ok", "status": "ok",
"messages": await app.ctx.message_store.count(), "messages": app.ctx.message_store.count(),
"users": await app.ctx.session_store.count(), "users": app.ctx.session_store.count(),
"timestamp": utcnow().isoformat(), "timestamp": utcnow().isoformat(),
} }
) )
async def clear_messages(request: Request, app: Sanic) -> HTTPResponse: async def clear_messages(request: Request, app: Sanic) -> HTTPResponse:
if err := require_auth(request, app): user_id = request.args.get("user_id")
return err if not user_id or not app.ctx.session_store.get(user_id):
await app.ctx.message_store.clear() return response.json({"error": "Unauthorized"}, status=401)
app.ctx.message_store.clear()
return json_response({"status": "cleared"}) return json_response({"status": "cleared"})

Binary file not shown.

View File

@ -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",
],
)

1
tests.bat Normal file
View File

@ -0,0 +1 @@
pytest tests/ -v

41
tests/conftest.py Normal file
View File

@ -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

17
tests/test_health.py Normal file
View File

@ -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

48
tests/test_srp.py Normal file
View File

@ -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

17
tests/test_websocket.py Normal file
View File

@ -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