Password update
This commit is contained in:
parent
28cbbf2cad
commit
b0ff612023
39
README.MD
39
README.MD
|
|
@ -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
|
|||
Here’s how it looks in action:
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
47
ROADMAP.md
Normal file
47
ROADMAP.md
Normal 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).
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user