Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 68 additions & 10 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ every "secure" messenger still stores metadata somewhere. this doesn't. it's jus

```
┌──────────────────────────────────────────────────────────────────┐
SRP AUTHENTICATION
TLS HANDSHAKE (transport)
├──────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT SERVER │
│ │ │ │
│ │══════════════ TCP CONNECT ══════════════════►│ │
│ │══════════ TLS 1.2+ CONNECT ════════════════►│ │
│ │ (cert verified or --no-verify) │ │
│ │ │ │
├──────────────────────────────────────────────────────────────────┤
│ SRP AUTHENTICATION │
├──────────────────────────────────────────────────────────────────┤
│ │
│ │─── {"cmd":"srp_init","username","A"} ──────► │ │
│ │ (A = client public ephemeral) │ │
│ │ │ │
Expand Down Expand Up @@ -67,9 +71,10 @@ every "secure" messenger still stores metadata somewhere. this doesn't. it's jus
│ │ [all clients with same password │ │
│ │ derive identical room_key] │ │
│ │ │ │
│ │ Encryption: Fernet (AES-128-CBC + HMAC) │ │
│ │ Transport: TLS 1.2+ (RSA-2048) │ │
│ │ Encryption: Fernet (AES-128-CBC + HMAC) │ │
│ │ Key derivation: HKDF-SHA256 │ │
│ │ Protocol: newline-delimited JSON over TCP │ │
│ │ Protocol: newline-delimited JSON over TLS │ │
│ │ │ │
│ │ [on disconnect: keys wiped from RAM] │ │
│ │ │ │
Expand All @@ -79,6 +84,8 @@ every "secure" messenger still stores metadata somewhere. this doesn't. it's jus
│ KEY HIERARCHY │
├──────────────────────────────────────────────────────────────────┤
│ │
│ TLS cert ──────────────────────────────► transport encryption │
│ │
│ password ──┬──► SRP ──► session_key (per-user, auth only) │
│ │ │
│ └──► HKDF(password, room_salt) ──► room_key (shared) │
Expand All @@ -89,10 +96,30 @@ every "secure" messenger still stores metadata somewhere. this doesn't. it's jus
└──────────────────────────────────────────────────────────────────┘
```

**TLS** — encrypts and authenticates the transport layer. the server auto-generates a self-signed certificate on startup; provide your own cert/key pair for production.

**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.

## install

quick start scripts:

```bash
./start.sh serve 0.0.0.0 3000 --password mysecret
./start.sh connect SERVER_IP 3000 username mysecret --no-verify
```

windows:

```bat
start.bat serve 0.0.0.0 3000 --password mysecret
start.bat connect SERVER_IP 3000 username mysecret --no-verify
```

the scripts create `venv`, install `requirements.txt` when needed, then run the chat command you pass in.

manual setup:

```bash
git clone https://github.com/emilycodestar/cmd-chat.git
cd cmd-chat
Expand All @@ -107,28 +134,59 @@ python -m venv venv ; .\venv\Scripts\activate ; pip install -r requirements.txt

## usage

start server:
### server

```bash
./start.sh serve 0.0.0.0 3000 --password mysecret
```

the server auto-generates a self-signed TLS certificate and prints it to stdout. to use it for client verification, save it:

```bash
python cmd_chat.py serve 0.0.0.0 3000 --password mysecret
./start.sh serve 0.0.0.0 3000 --password mysecret > server.crt
```

connect:
to use your own certificate and key:

```bash
python cmd_chat.py connect SERVER_IP 3000 username mysecret
./start.sh serve 0.0.0.0 3000 --password mysecret --cert /path/to/cert.pem --key /path/to/key.pem
```

### client

connect and skip certificate verification (quick/local use — transport is still encrypted):

```bash
./start.sh connect SERVER_IP 3000 username mysecret --no-verify
```

connect and verify the server's certificate (recommended for untrusted networks):

```bash
./start.sh connect SERVER_IP 3000 username mysecret --ca-cert server.crt
```

### tls flags reference

| flag | side | description |
|---|---|---|
| `--cert CERT_PEM` | server | path to TLS certificate (PEM). auto-generated if omitted |
| `--key KEY_PEM` | server | path to TLS private key (PEM). required when `--cert` is given |
| `--ca-cert CA_PEM` | client | path to server's certificate for verification |
| `--no-verify` | client | skip certificate verification (transport remains encrypted) |

![Example](example.gif)

## features

- **tls transport** — TLS 1.2+ on every connection, auto-generated self-signed cert
- **ram only** — nothing touches disk
- **pure sockets** — no http, no websocket, just raw tcp
- **pure sockets** — no http, no websocket, just raw tcp over tls
- **srp auth** — password never sent over network
- **e2e encryption** — Fernet (AES-128-CBC + HMAC)
- **zero dependencies on web frameworks** — only asyncio

## license

MIT

14 changes: 13 additions & 1 deletion cmd_chat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,35 @@ def main():
serve_p.add_argument("ip_address")
serve_p.add_argument("port")
serve_p.add_argument("--password", "-p", required=True)
serve_p.add_argument("--cert", metavar="CERT_PEM", help="Path to TLS certificate (PEM). Auto-generated if omitted.")
serve_p.add_argument("--key", metavar="KEY_PEM", help="Path to TLS private key (PEM). Required when --cert is given.")

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")
connect_p.add_argument("--ca-cert", metavar="CA_PEM", help="Path to server's CA/self-signed certificate for verification.")
connect_p.add_argument("--no-verify", action="store_true", help="Disable TLS certificate verification (transport remains encrypted).")

args = parser.parse_args()

if args.command == "serve":
run_server(host=args.ip_address, port=int(args.port), password=args.password)
run_server(
host=args.ip_address,
port=int(args.port),
password=args.password,
cert=args.cert,
key=args.key,
)
elif args.command == "connect":
Client(
server=args.ip_address,
port=int(args.port),
username=args.username,
password=args.password,
ca_cert=args.ca_cert,
no_verify=args.no_verify,
).run()


Expand Down
35 changes: 31 additions & 4 deletions cmd_chat/client/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import json
import base64
import ssl
from typing import Optional

import srp
Expand All @@ -26,12 +27,20 @@

class Client:
def __init__(
self, server: str, port: int, username: str, password: Optional[str] = None
self,
server: str,
port: int,
username: str,
password: Optional[str] = None,
ca_cert: Optional[str] = None,
no_verify: bool = False,
):
self.server = server
self.port = port
self.username = username
self.password = (password or "").encode()
self.ca_cert = ca_cert
self.no_verify = no_verify
self.user_id: Optional[str] = None
self.fernet: Optional[Fernet] = None
self.room_fernet: Optional[Fernet] = None
Expand Down Expand Up @@ -201,7 +210,7 @@ async def receive_loop(self) -> None:
self.connected = False

async def input_loop(self) -> None:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
while self.running:
try:
text = await loop.run_in_executor(None, input)
Expand All @@ -214,6 +223,11 @@ async def input_loop(self) -> None:
except (EOFError, KeyboardInterrupt):
self.running = False
break
except OSError:
# Windows raises WinError 64 (network name no longer available)
# when the server closes the connection while we're draining.
self.running = False
break
except asyncio.CancelledError:
break

Expand All @@ -222,10 +236,23 @@ async def run_async(self) -> None:
self.console.print(BANNER)
self.console.print()

from cmd_chat.tls import make_client_ssl_context

ssl_context: ssl.SSLContext = make_client_ssl_context(
ca_cert_path=self.ca_cert,
no_verify=self.no_verify,
)

try:
self.info(f"Connecting to {self.server}:{self.port}...")
self.info(f"Connecting to {self.server}:{self.port} (TLS)...")
self.reader, self.writer = await asyncio.wait_for(
asyncio.open_connection(self.server, self.port), timeout=10.0
asyncio.open_connection(
self.server,
self.port,
ssl=ssl_context,
server_hostname=self.server,
),
timeout=10.0,
)
self.success("Connected")

Expand Down
29 changes: 25 additions & 4 deletions cmd_chat/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
import base64
import os
import ssl
from dataclasses import asdict
from contextlib import suppress
from typing import Optional
Expand Down Expand Up @@ -35,11 +36,19 @@ def __init__(self, password: str):
self.room_salt = os.urandom(0x10)
self._cleanup_task: Optional[asyncio.Task] = None

async def start(self, host: str, port: int):
server = await asyncio.start_server(self._handle_client, host, port)
async def start(
self,
host: str,
port: int,
ssl_context: Optional[ssl.SSLContext] = None,
):
server = await asyncio.start_server(
self._handle_client, host, port, ssl=ssl_context
)
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
addr = server.sockets[0].getsockname()
print(f"[*] Server running on {addr[0]}:{addr[1]}")
proto = "TLS" if ssl_context else "plaintext"
print(f"[*] Server running on {addr[0]}:{addr[1]} ({proto})")
async with server:
await server.serve_forever()

Expand Down Expand Up @@ -247,9 +256,21 @@ def run_server(
host: str = "0.0.0.0",
port: int = 0x1F40,
password: Optional[str] = None,
cert: Optional[str] = None,
key: Optional[str] = None,
):
from cmd_chat.tls import make_server_ssl_context

ssl_context, cert_pem = make_server_ssl_context(cert, key, host)

if cert_pem:
print("[*] Generated self-signed TLS certificate.")
print("[*] Share the certificate below with clients using --ca-cert,")
print("[*] or use --no-verify to skip certificate verification.\n")
print(cert_pem.decode())

server = ChatServer(password or "")
try:
asyncio.run(server.start(host, port))
asyncio.run(server.start(host, port, ssl_context=ssl_context))
except KeyboardInterrupt:
print("\n[*] Shutting down...")
Loading