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
|
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
120
README.MD
|
|
@ -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.
|
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
[](#)
|
|
||||||
|
|
||||||
</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.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
|
||||||
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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 .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()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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 typing import Optional
|
||||||
|
|
||||||
from .models import Message, UserSession
|
from .models import Message, UserSession
|
||||||
from .logger import logger
|
from .logger import logger
|
||||||
|
|
||||||
|
|
@ -8,55 +6,44 @@ 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)
|
||||||
]
|
]
|
||||||
|
|
@ -66,14 +53,11 @@ class UserSessionStore:
|
||||||
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())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
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(
|
session = UserSession(
|
||||||
user_id=str(uuid4()),
|
user_id=user_id,
|
||||||
ip=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
username=get_param(request, "username") or "unknown",
|
username=username,
|
||||||
fernet_key=app.ctx.fernet_key,
|
fernet_key=fernet_key,
|
||||||
)
|
)
|
||||||
await app.ctx.session_store.add(session)
|
app.ctx.session_store.add(session)
|
||||||
|
|
||||||
try:
|
logger.info(f"SRP verified: {username} ({user_id[:8]}...)")
|
||||||
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(
|
return response.json(
|
||||||
encrypted_key,
|
{
|
||||||
content_type="application/octet-stream",
|
"H_AMK": base64.b64encode(H_AMK).decode(),
|
||||||
headers={"X-User-Id": session.user_id},
|
"session_key": base64.b64encode(fernet_key).decode(),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Encryption failed: {e}")
|
except ValueError as e:
|
||||||
return response.text("Key encryption failed", status=500)
|
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"})
|
||||||
|
|
|
||||||
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