Password update

This commit is contained in:
mirai 2025-09-10 19:58:59 +03:00
parent 28cbbf2cad
commit b0ff612023
7 changed files with 293 additions and 202 deletions

View File

@ -3,65 +3,57 @@
**CMD CHAT** is a new milestone in console communication.
A fully anonymous chat between two clients, impossible to intercept or hand over.
All data exists only in RAM and is wiped after the session ends.
No logs, no traces, no compromise.
No logs, no traces, no compromise.
---
## 🔒 Key Features
- Full anonymity
- End-to-End encryption (RSA + symmetric key)
- Data stored only in memory (RAM), deleted on exit
- No logging, no persistence on disk
- Easy to run via Python or CLI
- Full anonymity
- End-to-End encryption (RSA + symmetric key)
- Data stored only in memory (RAM), deleted on exit
- No logging, no persistence on disk
- Easy to run via Python or CLI
---
## ⚙️ How It Works
1. The client generates an RSA key pair.
2. The server creates a symmetric key.
3. The client sends its public key to the server.
4. The server encrypts the symmetric key and sends it back.
5. The client decrypts and confirms the key.
6. From that point, all communication is done via symmetric encryption.
1. The client generates an RSA key pair.
2. The server creates a symmetric key.
3. The client sends its public key to the server.
4. The server encrypts the symmetric key and sends it back.
5. The client decrypts and confirms the key.
6. From that point, all communication is done via symmetric encryption.
Everything happens in memory only. Nothing is written to disk.
Everything happens in memory only. Nothing is written to disk.
---
## 🚀 Installation & Run
### Option 1: Python
### Python
Install with pip:
```bash
pip install secured_console_chat
````
1. Clone the repository:
`git clone https://github.com/emilycodestar/cmd-chat.git`
`cd cmd-chat`
Run directly from Python:
2. Create a virtual environment and install dependencies:
```python
import asyncio
import cmd_chat
Linux / macOS:
`python -m venv venv && source venv/bin/activate && pip install -r requirements.txt`
if __name__ == '__main__':
asyncio.run(cmd_chat.run())
```
Windows (PowerShell):
`python -m venv venv ; .\venv\Scripts\activate ; pip install -r requirements.txt`
### Option 2: CLI
3. Start the server (set a password for client connections):
`python cmd_chat.py serve 0.0.0.0 1000 --password YOUR_PASSWORD`
Start the server:
4. Connect a client:
`python cmd_chat.py connect SERVER_IP 1000 USERNAME YOUR_PASSWORD`
```bash
cmd_chat serve localhost 5000
```
Connect to the server:
```bash
cmd_chat connect localhost 5000 tyler
```
Example (local run):
`python cmd_chat.py connect localhost 1000 tyler YOUR_PASSWORD`
---
@ -70,4 +62,3 @@ cmd_chat connect localhost 5000 tyler
Heres how it looks in action:
![Example](example.gif)

47
ROADMAP.md Normal file
View File

@ -0,0 +1,47 @@
## 🗺️ Roadmap
### 1. Core stability
- [ ] Switch from `ast.literal_eval` to `json` for messages.
- [ ] Add proper reconnect with heartbeat (ping/pong, timeouts).
- [ ] Clean error handling (no leaking stack traces to clients).
- [ ] Limit message size and frequency (basic anti-spam).
### 2. Security improvements
- [ ] Per-client symmetric keys instead of one global key.
- [ ] Upgrade RSA 512 → 2048 (or curve25519 ECDH + HKDF).
- [ ] Replace shared password with invite tokens or session-based bearer tokens.
- [ ] Force WSS (TLS) for production.
### 3. Chat features
- [ ] Multiple rooms (room_id support).
- [ ] Commands (`/nick`, `/clear`, `/help`, `/quit`).
- [ ] Message timestamps + sequence numbers.
- [ ] Delta updates (only send new messages instead of full history).
### 4. UX / Client
- [ ] Local encrypted history (optional).
- [ ] Customizable renderers (rich, minimal, json mode).
- [ ] Quiet reconnection status indicator.
- [ ] Configurable message buffer length.
### 5. File & media
- [ ] File transfer via encrypted chunks.
- [ ] Inline images (optional, in rich renderer).
### 6. Deployment & Ops
- [ ] Dockerfile + docker-compose (server + client).
- [ ] Add uvloop + multiple Sanic workers.
- [ ] Graceful shutdown & restart.
- [ ] Systemd service unit for server.
### 7. Privacy & audit
- [ ] Disable sensitive logs (no passwords/tokens in logs).
- [ ] Minimal server metrics: connected users, msg/sec.
- [ ] Configurable retention (in-memory only vs file-based).

View File

@ -1,64 +1,38 @@
import asyncio
import asyncio
import argparse
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(ip, int(port), False, password)
def run_http_server(
ip: str,
port: int
) -> None:
run_server(ip, port, False)
async def run_client(
username: str,
server: str,
port: int
) -> None:
Client(
server = server,
port = port,
username = username
).run()
async def run_client(username: str, server: str, port: int, password: str | None) -> None:
Client(server=server, port=port, username=username, password=password).run()
async def run() -> None:
parser = argparse.ArgumentParser(
description='Command-line chat application'
)
parser.add_argument(
'command',
choices=['serve', 'connect'],
help='Command to execute'
)
parser.add_argument(
'ip_address',
help='IP address to serve or connect'
)
parser.add_argument(
'port',
help='PORT of server'
)
parser.add_argument(
'username',
nargs='?',
default='',
help='Username for connection (required for connect command)'
)
args = parser.parse_args()
if args.command == 'serve':
run_http_server(args.ip_address, int(args.port))
elif args.command == 'connect':
if not args.username:
parser.error("Username is required for 'connect' command")
await run_client(args.username, args.ip_address, int(args.port))
parser = argparse.ArgumentParser(description='Command-line chat application')
subparsers = parser.add_subparsers(dest='command', required=True)
serve_p = subparsers.add_parser('serve', help='Run server')
serve_p.add_argument('ip_address')
serve_p.add_argument('port')
serve_p.add_argument('--password', '-p', required=True, help='Admin password required for clients')
connect_p = subparsers.add_parser('connect', help='Connect to server')
connect_p.add_argument('ip_address')
connect_p.add_argument('port')
connect_p.add_argument('username')
connect_p.add_argument('password', help='Password to auth on server')
args = parser.parse_args()
if args.command == 'serve':
run_http_server(args.ip_address, args.port, args.password)
elif args.command == 'connect':
await run_client(args.username, args.ip_address, int(args.port), args.password)
def main():
asyncio.run(run())
if __name__ == '__main__':
main()
main()

View File

@ -1,8 +1,9 @@
import ast
import ast
import time
import threading
from typing import Optional
from websocket import create_connection
from websocket import create_connection, WebSocketConnectionClosedException
from cmd_chat.client.core.crypto import RSAService
from cmd_chat.client.core.default_renderer import DefaultClientRenderer
@ -14,107 +15,157 @@ from cmd_chat.client.config import RENDER_TIME
class Client(RSAService, RichClientRenderer):
def __init__(
self,
server: str,
port: int,
username: str
self,
server: str,
port: int,
username: str,
password: Optional[str] = None
):
super().__init__()
# Server info
self.server = server
self.port = port
self.username = username
# Urls
self.password = password or ""
self.base_url = f"http://{self.server}:{self.port}"
self.talk_url = f"{self.base_url}/talk"
self.info_url = f"{self.base_url}/update"
self.key_url = f"{self.base_url}/get_key"
self.ws_url = f"ws://{self.server}:{self.port}"
self.close_response = str({
"action": "close",
"username": self.username
})
# Threads
self.__stop_threads = False
self.__stop_threads = False
def _ws_full(self, path: str) -> str:
if self.password:
return f"{self.ws_url}{path}?password={self.password}"
return f"{self.ws_url}{path}"
def _connect_ws(self, path: str, retries: int = 5, backoff: float = 0.5):
last_exc = None
for attempt in range(retries):
try:
return create_connection(self._ws_full(path))
except Exception as exc:
last_exc = exc
time.sleep(backoff * (2 ** attempt))
print(f"Can't connect to {path}: {last_exc}")
raise last_exc
def send_info(self):
""" sending message to websocket
"""
ws = create_connection(f"{self.ws_url}/talk")
while not self.__stop_threads:
ws = self._connect_ws("/talk")
try:
while not self.__stop_threads:
try:
user_input = input("You're message: ")
if user_input == "q":
self.__stop_threads = True
try:
if ws:
ws.send(self.close_response)
ws.close()
except Exception:
pass
break
message = f'{self.username}: {user_input}'
socket_message = str({
"text": self._encrypt(message),
"username": self.username
})
ws.send(socket_message)
except (WebSocketConnectionClosedException, ConnectionResetError, ConnectionAbortedError, OSError):
try:
if ws:
try:
ws.close()
except Exception:
pass
ws = self._connect_ws("/talk")
continue
except Exception:
print("Can't establish channel")
self.__stop_threads = True
break
except KeyboardInterrupt:
self.__stop_threads = True
try:
ws.send(self.close_response)
ws.close()
except Exception:
pass
break
finally:
try:
user_input = input("You're message: ")
if user_input == "q":
self.__stop_threads = True
message = f'{self.username}: {user_input}'
socket_message = str({
"text": self._encrypt(message),
"username": self.username
})
ws.send(payload=socket_message.encode())
except KeyboardInterrupt:
ws.send(self.close_response)
ws.close()
self.__stop_threads = True
except Exception as exc:
ws.send(self.close_response)
ws.close()
print("Something went wrong! ", exc)
self.__stop_threads = True
raise exc
except Exception:
pass
def update_info(self):
""" connecting to websocket,
wating for updates,
updating every RENDER_TIME seconds
"""
ws = create_connection(f"{self.ws_url}/update")
ws = self._connect_ws("/update")
last_try = None
while not self.__stop_threads:
try:
while not self.__stop_threads:
try:
time.sleep(RENDER_TIME)
raw = ws.recv()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
response = ast.literal_eval(raw)
if last_try == response:
continue
last_try = response
self.clear_console()
if len(last_try["messages"]) > 0:
self.print_chat(response=last_try)
except (WebSocketConnectionClosedException, ConnectionResetError, ConnectionAbortedError, OSError):
try:
if ws:
try:
ws.close()
except Exception:
pass
ws = self._connect_ws("/update")
continue
except Exception:
print("Connection lost: can't establish update channel")
self.__stop_threads = True
break
except KeyboardInterrupt:
self.__stop_threads = True
try:
ws.send(self.close_response)
ws.close()
except Exception:
pass
break
finally:
try:
time.sleep(RENDER_TIME)
response = ast.literal_eval(ws.recv().decode('utf-8'))
if last_try == response:
continue
last_try = response
self.clear_console()
if len(last_try["messages"]) > 0:
self.print_chat(response = last_try)
except KeyboardInterrupt:
ws.send(self.close_response)
ws.close()
self.__stop_threads = True
except ConnectionAbortedError:
# Reconnect if somehow client was disconnected
ws = create_connection(f"{self.ws_url}/update")
continue
except Exception as exc:
ws.send(self.close_response)
ws.close()
print("Something went wrong! ", exc)
self.__stop_threads = True
raise exc
except Exception:
pass
def _validate_keys(self) -> None:
self._request_key(self.key_url, self.username)
self._request_key(
url=f"{self.base_url}/get_key",
username=self.username,
password=self.password
)
self._remove_keys()
def run(self):
# Running two threads,
# One for sending info
# Second one for updating info
self._validate_keys()
threads = [
threading.Thread(target=self.send_info),
threading.Thread(target=self.update_info)
threading.Thread(target=self.send_info, daemon=True),
threading.Thread(target=self.update_info, daemon=True)
]
for th in threads:
th.start()
for th in threads:
th.join()
if __name__ == '__main__':
Client(
server=input("server ip:\n"),
port=int(input("server port: \n")),
username=input("username:\n").replace(" ", "").lower()
username=input("username:\n").replace(" ", "").lower(),
password=input("password:\n")
).run()

View File

@ -1,6 +1,5 @@
from abc import ABC, abstractmethod
class CryptoService(ABC):
@abstractmethod
@ -12,7 +11,7 @@ class CryptoService(ABC):
raise NotImplementedError("Need to implement decrypt method")
@abstractmethod
def _request_key(self, url: str, username: str):
def _request_key(self, url: str, username: str, password: str | None = None):
raise NotImplementedError("Need to implement request key method")
@abstractmethod

View File

@ -19,17 +19,20 @@ class RSAService(CryptoService):
self._generate_keys()
def _encrypt(self, message: str) -> str:
return self.fernet.encrypt(message.encode())
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):
def _request_key(self, url: str, username: str, password: str | None = None):
data = {
"pubkey": self._open_generated_file(self.public_key_name),
"username": username
}
r = requests.get(url, data=data, stream=True)
if password:
data["password"] = password
r = requests.post(url, files={"pubkey": ("public.pem", data["pubkey"])}, data={"username": username, "password": password or ""}, stream=True)
r.raise_for_status()
message = r.raw.read(999)
self.symmetric_key = rsa.decrypt(message, self.private_key)
self.fernet = Fernet(self.symmetric_key)
@ -63,4 +66,7 @@ class RSAService(CryptoService):
def _remove_keys(self):
for key in self.keys_path:
os.remove(key)
try:
os.remove(key)
except FileNotFoundError:
pass

View File

@ -1,15 +1,10 @@
import asyncio
import asyncio
import rsa
from cryptography.fernet import Fernet
from functools import partial
from sanic.worker.loader import AppLoader
from sanic.response import HTTPResponse
from sanic import Sanic, Request, response, Websocket
from cmd_chat.server.models import Message
from cmd_chat.server.services import (
_get_bytes_and_serialize,
@ -18,70 +13,98 @@ from cmd_chat.server.services import (
_generate_update_payload
)
app = Sanic("app")
app.config.OAS = False
# Message structure is:
# [username: message, ...]
MESSAGES_MEMORY_DB: list[Message] = []
# Users structure is
# {Ip, Username: Public key}
USERS: dict[str, str] = {}
PUBLIC_KEY = Fernet.generate_key()
def attach_endpoints(app: Sanic):
def _check_password(request: Request, expected: str | None) -> bool:
if not expected:
return True
q = request.args.get("password")
f = request.form.get("password") if hasattr(request, "form") else None
return (q or f) == expected
def _get_str_arg(request: Request, name: str) -> str | None:
return request.form.get(name) or request.args.get(name)
def attach_endpoints(app: Sanic):
@app.websocket("/talk")
async def talk_ws_view(request: Request, ws: Websocket) -> HTTPResponse:
if not _check_password(request, app.ctx.ADMIN_PASSWORD):
await ws.close(code=4001, reason="unauthorized")
return
while True:
serialized_message: dict = await _get_bytes_and_serialize(ws)
await _check_ws_for_close_status(
serialized_message,
ws
)
new_message = await _generate_new_message(
serialized_message.get("text")
)
await _check_ws_for_close_status(serialized_message, ws)
new_message = await _generate_new_message(serialized_message.get("text"))
MESSAGES_MEMORY_DB.append(new_message)
await ws.send(
str({"status": "ok"})
)
await ws.send(str({"status": "ok"}))
await asyncio.sleep(0.2)
@app.websocket("/update")
async def update_ws_view(request: Request, ws: Websocket) -> HTTPResponse:
if not _check_password(request, app.ctx.ADMIN_PASSWORD):
await ws.close(code=4001, reason="unauthorized")
return
while True:
payload = await _generate_update_payload(
MESSAGES_MEMORY_DB,
USERS
)
payload = await _generate_update_payload(MESSAGES_MEMORY_DB, USERS)
await ws.send(payload.encode())
await asyncio.sleep(0.2)
@app.route('/get_key', methods=['GET', 'POST'])
async def get_key_view(request: Request) -> HTTPResponse:
public_key = rsa.PublicKey.load_pkcs1(request.form.get('pubkey'))
if not _check_password(request, app.ctx.ADMIN_PASSWORD):
return response.text("unauthorized", status=401)
pubkey_bytes: bytes | None = None
if "pubkey" in request.files and request.files.get("pubkey"):
f = request.files.get("pubkey")
if isinstance(f, list):
f = f[0]
pubkey_bytes = f.body
if pubkey_bytes is None:
raw = request.form.get("pubkey")
if raw:
pubkey_bytes = raw if isinstance(raw, bytes) else str(raw).encode()
if pubkey_bytes is None:
raw = request.args.get("pubkey")
if raw:
pubkey_bytes = raw.encode()
if not pubkey_bytes:
return response.text("bad request: pubkey is required", status=400)
try:
public_key = rsa.PublicKey.load_pkcs1(pubkey_bytes)
except Exception as e:
return response.text(f"bad pubkey: {e}", status=400)
encrypted_data = rsa.encrypt(PUBLIC_KEY, public_key)
if request.ip not in USERS:
USERS[f"{request.ip}, {request.form.get('username')}"] = PUBLIC_KEY
username = _get_str_arg(request, "username") or "unknown"
user_key = f"{request.ip}, {username}"
if user_key not in USERS:
USERS[user_key] = PUBLIC_KEY
return response.raw(encrypted_data)
def create_app(app_name: str) -> Sanic:
def create_app(app_name: str, admin_password: str | None) -> Sanic:
app = Sanic(app_name)
app.ctx.ADMIN_PASSWORD = admin_password
attach_endpoints(app)
return app
return app
def run_server(host: str, port: int, dev: bool=False) -> None:
loader = AppLoader(factory=partial(create_app, "CMD_SERVER"))
def run_server(host: str, port: int, dev: bool = False, admin_password: str | None = None) -> None:
loader = AppLoader(factory=partial(create_app, "CMD_SERVER", admin_password))
app = loader.load()
app.prepare(host=host, port=port, dev=dev)
Sanic.serve(primary=app, app_loader=loader)