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:
parent
e3a3dd3f0f
commit
5cbe355660
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -12,4 +12,6 @@ __pycache__
|
|||
cmd_chat.egg-info
|
||||
build
|
||||
dist
|
||||
true.txt
|
||||
secured_console_chat.egg-info
|
||||
.pytest_cache/
|
||||
120
README.MD
120
README.MD
|
|
@ -1,98 +1,106 @@
|
|||
<div align="center">
|
||||
|
||||
# 🤐 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/)
|
||||
[](#)
|
||||
|
||||
</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
|
||||
|
||||
- **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.
|
||||
|
||||

|
||||
|
||||
<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
|
||||
|
||||
</div>
|
||||
MIT
|
||||
|
|
|
|||
47
ROADMAP.md
47
ROADMAP.md
|
|
@ -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).
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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)}")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
70
cmd_chat/server/srp_auth.py
Normal file
70
cmd_chat/server/srp_auth.py
Normal 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)
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from .models import Message, UserSession
|
||||
from .logger import logger
|
||||
|
||||
|
|
@ -8,55 +6,44 @@ 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:
|
||||
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:
|
||||
def get_all(self) -> list[Message]:
|
||||
return self._messages.copy()
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._lock:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
]
|
||||
|
|
@ -66,14 +53,11 @@ class UserSessionStore:
|
|||
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:
|
||||
def get_all(self) -> list[UserSession]:
|
||||
return list(self._sessions.values())
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self._lock:
|
||||
def count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
async def username_exists(self, username: str) -> bool:
|
||||
async with self._lock:
|
||||
def username_exists(self, username: str) -> bool:
|
||||
return any(s.username == username for s in self._sessions.values())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
if app.ctx.session_store.username_exists(username):
|
||||
return response.json({"error": "Username taken"}, status=409)
|
||||
|
||||
user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public)
|
||||
|
||||
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:
|
||||
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=str(uuid4()),
|
||||
user_id=user_id,
|
||||
ip=get_client_ip(request),
|
||||
username=get_param(request, "username") or "unknown",
|
||||
fernet_key=app.ctx.fernet_key,
|
||||
username=username,
|
||||
fernet_key=fernet_key,
|
||||
)
|
||||
await app.ctx.session_store.add(session)
|
||||
app.ctx.session_store.add(session)
|
||||
|
||||
try:
|
||||
encrypted_key = rsa.encrypt(app.ctx.fernet_key, public_key)
|
||||
logger.info(f"Key exchange: user={session.username}, session={session.user_id}")
|
||||
logger.info(f"SRP verified: {username} ({user_id[:8]}...)")
|
||||
|
||||
return response.raw(
|
||||
encrypted_key,
|
||||
content_type="application/octet-stream",
|
||||
headers={"X-User-Id": session.user_id},
|
||||
return response.json(
|
||||
{
|
||||
"H_AMK": base64.b64encode(H_AMK).decode(),
|
||||
"session_key": base64.b64encode(fernet_key).decode(),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Encryption failed: {e}")
|
||||
return response.text("Key encryption failed", status=500)
|
||||
|
||||
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"})
|
||||
|
|
|
|||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
29
setup.py
29
setup.py
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
41
tests/conftest.py
Normal file
41
tests/conftest.py
Normal 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
17
tests/test_health.py
Normal 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
48
tests/test_srp.py
Normal 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
17
tests/test_websocket.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user