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
269 changes: 176 additions & 93 deletions README.MD
Original file line number Diff line number Diff line change
@@ -1,133 +1,216 @@
<div align="center">

# 🤐 CMD-CHAT
# CMD-CHAT

### encrypted terminal chat. no servers. no logs. ram only.
### end-to-end encrypted terminal chat with file transfer

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)

</div>

---

peer-to-peer encrypted chat that runs in your terminal. you host, you control. close the window — everything's gone.
Encrypted chat that runs in your terminal. You host the server, you control the room. Close the window — everything's gone. Messages and files are encrypted client-side before the server ever sees them.

## why
## Features

every "secure" messenger still stores metadata somewhere. this doesn't. it's just two terminals talking over an encrypted tunnel. nothing written to disk, ever.
- **End-to-end encrypted** — messages encrypted with Fernet (AES-128-CBC + HMAC) before leaving your machine
- **TLS by default** — auto-generated self-signed certs, or bring your own
- **SRP authentication** — password never sent over the network (zero-knowledge proof)
- **Encrypted file transfer** — `/send`, `/accept`, `/reject` with SHA-256 verification
- **RAM only** — nothing written to disk on the server
- **Rate limiting** — brute-force protection on auth endpoints
- **No IP leaks** — client IPs never broadcast to other users
- **Password hidden** — prompted securely via `getpass`, never visible in `ps` or shell history

## how it works
## Install

```bash
git clone https://github.com/diorwave/cmd-chat.git
cd cmd-chat
pip install -r requirements.txt
```
┌──────────────────────────────────────────────────────────────────┐
│ SRP AUTHENTICATION │
├──────────────────────────────────────────────────────────────────┤
│ │
│ CLIENT SERVER │
│ │ │ │
│ │─────── POST /srp/init {username, A} ───────► │ │
│ │ (A = client public ephemeral) │ │
│ │ │ │
│ │◄──── {user_id, B, salt, room_salt} ───────── │ │
│ │ (B = server public ephemeral) │ │
│ │ (room_salt = E2E key derivation) │ │
│ │ │ │
│ │ [client derives room_key via HKDF: │ │
│ │ room_key = HKDF(password, room_salt)] │ │
│ │ │ │
│ │ [both sides compute SRP session key │ │
│ │ using password + ephemeral values] │ │
│ │ │ │
│ │─────── POST /srp/verify {user_id, M} ──────► │ │
│ │ (M = client proof) │ │
│ │ │ │
│ │◄────────── {H_AMK, session_key} ──────────── │ │
│ │ (H_AMK = server proof) │ │
│ │ │ │
│ │ [password never transmitted] │ │
│ │ [MITM can't derive session key] │ │
│ │ │ │
├──────────────────────────────────────────────────────────────────┤
│ E2E ENCRYPTED CHAT │
├──────────────────────────────────────────────────────────────────┤
│ │ │ │
│ │═══════ WebSocket /ws/chat?user_id ═════════► │ │
│ │ (authenticated session) │ │
│ │ │ │
│ │ │ │
│ ┌─┴─┐ ┌──┴──┐ │
│ │ C │──── encrypt(msg, room_key) ───────────►│ S │ │
│ │ L │ │ E │ │
│ │ I │◄─── ciphertext (broadcast) ────────────│ R │ │
│ │ E │ │ V │ │
│ │ N │ decrypt(ciphertext, room_key) │ E │ │
│ │ T │ │ R │ │
│ └─┬─┘ └──┬──┘ │
│ │ │ │
│ │ [server stores ONLY ciphertext] │ │
│ │ [server CANNOT read messages] │ │
│ │ [all clients with same password │ │
│ │ derive identical room_key] │ │
│ │ │ │
│ │ Encryption: Fernet (AES-128-CBC + HMAC) │ │
│ │ Key derivation: HKDF-SHA256 │ │
│ │ │ │
│ │ [on disconnect: keys wiped from RAM] │ │
│ │ │ │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│ KEY HIERARCHY │
├──────────────────────────────────────────────────────────────────┤
│ │
│ password ──┬──► SRP ──► session_key (per-user, auth only) │
│ │ │
│ └──► HKDF(password, room_salt) ──► room_key (shared) │
│ │
│ room_salt: generated once at server start │
│ room_key: deterministic, same for all clients with same pwd │
│ │
└──────────────────────────────────────────────────────────────────┘

## Quick Start

**Host a chat room:**

```bash
python3 cmd_chat.py serve 0.0.0.0 3000
```

**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.
You'll be prompted for a room password (hidden input). An admin token and TLS cert path will print to the console.

## install
**Connect to a chat room:**

```bash
python -m venv venv && source venv/bin/activate && pip install -r requirements.txt
python3 cmd_chat.py connect SERVER_IP 3000 yourname --insecure
```

windows:
`--insecure` is needed for self-signed certs. You'll be prompted for the room password.

## Securing Your Connection

### Tailscale (recommended)

Both parties install [Tailscale](https://tailscale.com). Traffic goes through an encrypted WireGuard tunnel. No port forwarding, works across NATs.

```bash
python -m venv venv ; .\venv\Scripts\activate ; pip install -r requirements.txt
# Host
python3 cmd_chat.py serve 0.0.0.0 3000

# Friend connects using your Tailscale IP
python3 cmd_chat.py connect 100.x.x.x 3000 theirname --insecure
```

## usage
Find your Tailscale IP: `tailscale ip -4`

### LAN (same network)

start server:
Use your local IP. Both devices must be on the same WiFi/network.

```bash
python cmd_chat.py serve 0.0.0.0 3000 --password mysecret
python3 cmd_chat.py connect 192.168.1.x 3000 theirname --insecure
```

connect:
### Public Internet

Requires port forwarding on your router (TCP port 3000 to your machine). Use `--cert` and `--key` with a real certificate for production use.

```bash
python cmd_chat.py connect SERVER_IP 3000 username mysecret
python3 cmd_chat.py connect PUBLIC_IP 3000 theirname --insecure
```

![Example](example.gif)
Find your public IP: `curl ifconfig.me`

## Sharing the Room Password

The password must be shared outside the chat. Never send it over an unencrypted channel.

1. **In person** — tell them verbally
2. **Signal** — disappearing message set to 30 seconds
3. **One-time link** — [onetimesecret.com](https://onetimesecret.com) (self-destructs after one view)
4. **Split it** — send half via Telegram, half via SMS

## Chat Commands

## features
| Command | Action |
|---------|--------|
| `/send <filepath>` | Propose a file transfer to the room |
| `/accept` | Accept a pending file offer |
| `/reject` | Decline a pending file offer |
| `q` | Disconnect |

- **ram only** — nothing touches disk
- **rsa + aes** — key exchange + symmetric encryption
- **no central server** — direct p2p connection
- **srp auth** — password never sent over network
### File Transfer

Files are chunked (64KB), encrypted with the room key, and relayed through the server as opaque ciphertext. The server never sees file names, contents, or metadata.

```
alice> /send report.pdf
bob> "alice wants to send report.pdf (1.2 MB) — /accept or /reject"
bob> /accept
Receiving: 100% (1.2 MB/1.2 MB)
File saved: ./downloads/report.pdf — SHA-256 verified
```

- Max file size: 50 MB
- Files saved to `./downloads/` relative to where the client was launched
- SHA-256 integrity check on every transfer

## CLI Reference

### Server

```bash
python3 cmd_chat.py serve <bind_ip> <port> [options]
```

| Flag | Purpose |
|------|---------|
| `--password`, `-p` | Room password (prompted if omitted) |
| `--cert` | Path to TLS certificate |
| `--key` | Path to TLS private key |
| `--no-tls` | Disable TLS (local dev only) |

### Client

```bash
python3 cmd_chat.py connect <server_ip> <port> <username> [options]
```

| Flag | Purpose |
|------|---------|
| `--password`, `-p` | Room password (prompted if omitted) |
| `--insecure`, `-k` | Skip TLS cert verification (self-signed certs) |
| `--no-tls` | Connect without TLS |

### Environment Variable

Set `CMD_CHAT_PASSWORD` to skip the password prompt for both server and client.

## Helper Scripts

### `./lab/host-chat.sh`

One-command server setup. Detects your IPs (Tailscale, LAN, public), prints the exact connect command your friend needs, then starts the server.

```bash
./lab/host-chat.sh # TLS on port 4000
./lab/host-chat.sh --port 5000 # custom port
./lab/host-chat.sh --no-tls # disable TLS
```

### `./lab/setup-lab.sh`

Spins up a tmux session with the server and two chat clients side-by-side for local testing.

```bash
./lab/setup-lab.sh # default lab
./lab/setup-lab.sh --no-tls --port 4001 # plain HTTP
./lab/setup-lab.sh --user1 alice --user2 bob
./lab/setup-lab.sh --teardown # clean up
```

Attach with `tmux attach -t cmd-chat-lab`. Switch panes with `Ctrl+B` then arrow keys.

## How It Works

```
CLIENT SERVER CLIENT
│ │ │
│── POST /srp/init {A} ──────────► │ │
│◄── {B, salt, room_salt} ──────── │ │
│ │ │
│ derive room_key = HKDF(password, room_salt) │
│ │ │
│── POST /srp/verify {M} ────────► │ │
│◄── {H_AMK, ws_token} ─────────── │ │
│ │ │
│══ WSS /ws/chat?ws_token ════════► │ ◄══════════════════════════════│
│ │ │
│ encrypt(msg, room_key) ────────► │ ──── ciphertext ────────────► │
│ │ decrypt(ciphertext, room_key)
│ │ │
│ server stores ONLY ciphertext │ │
│ server CANNOT read messages │ │
```

**SRP (Secure Remote Password)** — both sides prove they know the password without transmitting it. A network observer learns nothing.

**Room Key** — derived independently by each client via `HKDF(password, room_salt)`. All clients with the same password get the same key. The server never has the key.

**WebSocket Auth** — HMAC-SHA256 token issued after SRP verification. Prevents session hijacking.

## Admin

The server prints an admin token at startup. Use it to clear message history:

```bash
curl -k -X DELETE https://SERVER:3000/clear \
-H "Authorization: Bearer <admin-token>"
```

## license
## License

MIT
41 changes: 37 additions & 4 deletions cmd_chat/__init__.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,66 @@
import argparse
import getpass
import os

from cmd_chat.server.server import run_server
from cmd_chat.client.client import Client


def resolve_password(args_password: str | None, prompt: str = "Room password: ") -> str:
if args_password:
return args_password
if env_pw := os.environ.get("CMD_CHAT_PASSWORD"):
return env_pw
return getpass.getpass(prompt)


def main():
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)
serve_p.add_argument("--password", "-p", default=None)
serve_p.add_argument("--cert", default=None, help="Path to TLS certificate")
serve_p.add_argument("--key", default=None, help="Path to TLS private key")
serve_p.add_argument("--no-tls", action="store_true", help="Disable TLS (insecure)")

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("--password", "-p", default=None)
connect_p.add_argument(
"--insecure", "-k", action="store_true",
help="Skip TLS certificate verification (for self-signed certs)",
)
connect_p.add_argument(
"--no-tls", action="store_true",
help="Connect without TLS (insecure)",
)

args = parser.parse_args()

if args.command == "serve":
run_server(host=args.ip_address, port=int(args.port), password=args.password)
password = resolve_password(args.password)
run_server(
host=args.ip_address,
port=int(args.port),
password=password,
cert_path=args.cert,
key_path=args.key,
no_tls=args.no_tls,
)
elif args.command == "connect":
password = resolve_password(args.password)
Client(
server=args.ip_address,
port=int(args.port),
username=args.username,
password=args.password,
password=password,
insecure=args.insecure,
no_tls=args.no_tls,
).run()


Expand Down
Loading