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
### 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 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()

View File

@ -1,8 +1,9 @@
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
@ -17,104 +18,154 @@ class Client(RSAService, RichClientRenderer):
self,
server: str,
port: int,
username: str
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
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")
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(payload=socket_message.encode())
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()
self.__stop_threads = True
except Exception as exc:
ws.send(self.close_response)
except Exception:
pass
break
finally:
try:
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
try:
while not self.__stop_threads:
try:
time.sleep(RENDER_TIME)
response = ast.literal_eval(ws.recv().decode('utf-8'))
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 KeyboardInterrupt:
ws.send(self.close_response)
except (WebSocketConnectionClosedException, ConnectionResetError, ConnectionAbortedError, OSError):
try:
if ws:
try:
ws.close()
self.__stop_threads = True
except ConnectionAbortedError:
# Reconnect if somehow client was disconnected
ws = create_connection(f"{self.ws_url}/update")
except Exception:
pass
ws = self._connect_ws("/update")
continue
except Exception as exc:
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()
print("Something went wrong! ", exc)
self.__stop_threads = True
raise exc
except Exception:
pass
break
finally:
try:
ws.close()
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:
try:
os.remove(key)
except FileNotFoundError:
pass

View File

@ -1,15 +1,10 @@
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
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)