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

@ -32,36 +32,28 @@ Everything happens in memory only. Nothing is written to disk.
## 🚀 Installation & Run ## 🚀 Installation & Run
### Option 1: Python ### Python
Install with pip: 1. Clone the repository:
```bash `git clone https://github.com/emilycodestar/cmd-chat.git`
pip install secured_console_chat `cd cmd-chat`
````
Run directly from Python: 2. Create a virtual environment and install dependencies:
```python Linux / macOS:
import asyncio `python -m venv venv && source venv/bin/activate && pip install -r requirements.txt`
import cmd_chat
if __name__ == '__main__': Windows (PowerShell):
asyncio.run(cmd_chat.run()) `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 Example (local run):
cmd_chat serve localhost 5000 `python cmd_chat.py connect localhost 1000 tyler YOUR_PASSWORD`
```
Connect to the server:
```bash
cmd_chat connect localhost 5000 tyler
```
--- ---
@ -70,4 +62,3 @@ cmd_chat connect localhost 5000 tyler
Heres how it looks in action: Heres how it looks in action:
![Example](example.gif) ![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 import argparse
from cmd_chat.server.server import run_server 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(ip, int(port), False, password)
def run_http_server( async def run_client(username: str, server: str, port: int, password: str | None) -> None:
ip: str, Client(server=server, port=port, username=username, password=password).run()
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() -> None: async def run() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description='Command-line chat application')
description='Command-line chat application' subparsers = parser.add_subparsers(dest='command', required=True)
)
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))
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(): def main():
asyncio.run(run()) asyncio.run(run())
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@ -1,8 +1,9 @@
import ast import ast
import time import time
import threading 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.crypto import RSAService
from cmd_chat.client.core.default_renderer import DefaultClientRenderer from cmd_chat.client.core.default_renderer import DefaultClientRenderer
@ -17,104 +18,154 @@ class Client(RSAService, RichClientRenderer):
self, self,
server: str, server: str,
port: int, port: int,
username: str username: str,
password: Optional[str] = None
): ):
super().__init__() super().__init__()
# Server info
self.server = server self.server = server
self.port = port self.port = port
self.username = username self.username = username
# Urls self.password = password or ""
self.base_url = f"http://{self.server}:{self.port}" 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.ws_url = f"ws://{self.server}:{self.port}"
self.close_response = str({ self.close_response = str({
"action": "close", "action": "close",
"username": self.username "username": self.username
}) })
# Threads
self.__stop_threads = False self.__stop_threads = False
def send_info(self): def _ws_full(self, path: str) -> str:
""" sending message to websocket if self.password:
""" return f"{self.ws_url}{path}?password={self.password}"
ws = create_connection(f"{self.ws_url}/talk") return f"{self.ws_url}{path}"
while not self.__stop_threads:
def _connect_ws(self, path: str, retries: int = 5, backoff: float = 0.5):
last_exc = None
for attempt in range(retries):
try: try:
user_input = input("You're message: ") return create_connection(self._ws_full(path))
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: except Exception as exc:
ws.send(self.close_response) last_exc = exc
time.sleep(backoff * (2 ** attempt))
print(f"Can't connect to {path}: {last_exc}")
raise last_exc
def send_info(self):
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:
ws.close() ws.close()
print("Something went wrong! ", exc) except Exception:
self.__stop_threads = True pass
raise exc
def update_info(self): def update_info(self):
""" connecting to websocket, ws = self._connect_ws("/update")
wating for updates,
updating every RENDER_TIME seconds
"""
ws = create_connection(f"{self.ws_url}/update")
last_try = None 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: 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() ws.close()
self.__stop_threads = True except Exception:
except ConnectionAbortedError: pass
# 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
def _validate_keys(self) -> None: 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() self._remove_keys()
def run(self): def run(self):
# Running two threads,
# One for sending info
# Second one for updating info
self._validate_keys() self._validate_keys()
threads = [ threads = [
threading.Thread(target=self.send_info), threading.Thread(target=self.send_info, daemon=True),
threading.Thread(target=self.update_info) threading.Thread(target=self.update_info, daemon=True)
] ]
for th in threads: for th in threads:
th.start() th.start()
for th in threads:
th.join()
if __name__ == '__main__': if __name__ == '__main__':
Client( Client(
server=input("server ip:\n"), server=input("server ip:\n"),
port=int(input("server port: \n")), port=int(input("server port: \n")),
username=input("username:\n").replace(" ", "").lower() username=input("username:\n").replace(" ", "").lower(),
password=input("password:\n")
).run() ).run()

View File

@ -1,6 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
class CryptoService(ABC): class CryptoService(ABC):
@abstractmethod @abstractmethod
@ -12,7 +11,7 @@ class CryptoService(ABC):
raise NotImplementedError("Need to implement decrypt method") raise NotImplementedError("Need to implement decrypt method")
@abstractmethod @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") raise NotImplementedError("Need to implement request key method")
@abstractmethod @abstractmethod

View File

@ -19,17 +19,20 @@ class RSAService(CryptoService):
self._generate_keys() self._generate_keys()
def _encrypt(self, message: str) -> str: 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: def _decrypt(self, message: str) -> str:
return self.fernet.decrypt(message.encode()).decode("utf-8") 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 = { data = {
"pubkey": self._open_generated_file(self.public_key_name), "pubkey": self._open_generated_file(self.public_key_name),
"username": username "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) message = r.raw.read(999)
self.symmetric_key = rsa.decrypt(message, self.private_key) self.symmetric_key = rsa.decrypt(message, self.private_key)
self.fernet = Fernet(self.symmetric_key) self.fernet = Fernet(self.symmetric_key)
@ -63,4 +66,7 @@ class RSAService(CryptoService):
def _remove_keys(self): def _remove_keys(self):
for key in self.keys_path: 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 import rsa
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from functools import partial from functools import partial
from sanic.worker.loader import AppLoader from sanic.worker.loader import AppLoader
from sanic.response import HTTPResponse from sanic.response import HTTPResponse
from sanic import Sanic, Request, response, Websocket from sanic import Sanic, Request, response, Websocket
from cmd_chat.server.models import Message from cmd_chat.server.models import Message
from cmd_chat.server.services import ( from cmd_chat.server.services import (
_get_bytes_and_serialize, _get_bytes_and_serialize,
@ -18,70 +13,98 @@ from cmd_chat.server.services import (
_generate_update_payload _generate_update_payload
) )
app = Sanic("app") app = Sanic("app")
app.config.OAS = False app.config.OAS = False
# Message structure is:
# [username: message, ...]
MESSAGES_MEMORY_DB: list[Message] = [] MESSAGES_MEMORY_DB: list[Message] = []
# Users structure is
# {Ip, Username: Public key}
USERS: dict[str, str] = {} USERS: dict[str, str] = {}
PUBLIC_KEY = Fernet.generate_key() 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") @app.websocket("/talk")
async def talk_ws_view(request: Request, ws: Websocket) -> HTTPResponse: 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: while True:
serialized_message: dict = await _get_bytes_and_serialize(ws) serialized_message: dict = await _get_bytes_and_serialize(ws)
await _check_ws_for_close_status( await _check_ws_for_close_status(serialized_message, ws)
serialized_message, new_message = await _generate_new_message(serialized_message.get("text"))
ws
)
new_message = await _generate_new_message(
serialized_message.get("text")
)
MESSAGES_MEMORY_DB.append(new_message) MESSAGES_MEMORY_DB.append(new_message)
await ws.send( await ws.send(str({"status": "ok"}))
str({"status": "ok"})
)
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
@app.websocket("/update") @app.websocket("/update")
async def update_ws_view(request: Request, ws: Websocket) -> HTTPResponse: 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: while True:
payload = await _generate_update_payload( payload = await _generate_update_payload(MESSAGES_MEMORY_DB, USERS)
MESSAGES_MEMORY_DB,
USERS
)
await ws.send(payload.encode()) await ws.send(payload.encode())
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
@app.route('/get_key', methods=['GET', 'POST']) @app.route('/get_key', methods=['GET', 'POST'])
async def get_key_view(request: Request) -> HTTPResponse: 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) 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) 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 = Sanic(app_name)
app.ctx.ADMIN_PASSWORD = admin_password
attach_endpoints(app) attach_endpoints(app)
return app return app
def run_server(host: str, port: int, dev: bool=False) -> None: def run_server(host: str, port: int, dev: bool = False, admin_password: str | None = None) -> None:
loader = AppLoader(factory=partial(create_app, "CMD_SERVER")) loader = AppLoader(factory=partial(create_app, "CMD_SERVER", admin_password))
app = loader.load() app = loader.load()
app.prepare(host=host, port=port, dev=dev) app.prepare(host=host, port=port, dev=dev)
Sanic.serve(primary=app, app_loader=loader) Sanic.serve(primary=app, app_loader=loader)