10 Commits

Author SHA1 Message Date
Siavash Sameni
1e2a83402d DESIGN.md: DNS-based key transparency, resolve remaining questions
- Key transparency via DNS TXT records with self-signatures
  (server can't MITM because it can't forge user's signature)
- Per-device ratchet sessions (Signal model), cross-device sync via seed
- LoRa deferred to later phases, not Phase 1
- Sealed sender before onion routing
- Phase 3 updated to include key transparency alongside federation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:55:15 +04:00
Siavash Sameni
fa20607e35 DESIGN.md: resolve open questions, add transport layer architecture
Decisions: Sender Keys for groups, optional onion routing, deniability
by default, Bluetooth + LoRa transports, no tokenization.

New sections: transport abstraction (HTTPS/WS/BT/LoRa/Wi-Fi Direct/USB),
LoRa compact binary format, sealed sender vs onion routing discussion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:44:47 +04:00
Siavash Sameni
b7aa1a10e8 Add DESIGN.md: warzone messenger architecture and roadmap
Covers: seed-based identity, Signal protocol (X3DH + Double Ratchet),
DNS federation, mule delivery protocol, Rust rewrite plan, ntfy integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:34:54 +04:00
Siavash Sameni
93c8c84de1 Click on DM lock icon to pre-fill /dm @username in input
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:04:31 +04:00
Siavash Sameni
811dd2c008 v14: /reply and /r command to quick-reply to last DM peer
- /reply <msg> or /r <msg> sends encrypted DM to last person
- lastDmPeer set when sending a DM or receiving one
- Shows error if no prior DM conversation exists

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:46:35 +04:00
Siavash Sameni
93be964d52 v14: persistent E2E keys - browser localStorage + server keys.json
Browser:
- ECDH key pair saved to localStorage (chat-key-priv, chat-key-pub)
- Loaded on reconnect, only generated once
- Re-registers public key with server on every connect
- Corrupted keys auto-regenerate

Server:
- Keys saved to keys.json on disk after each registration
- Loaded on startup, survives restarts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:21:20 +04:00
Siavash Sameni
04482faa6a Fix header commands readability: lighter text + styled code tags
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:17:32 +04:00
Siavash Sameni
03d91cb844 v13: E2E encrypted DMs via ECDH + AES-256-GCM (Web Crypto API)
Server:
- /keys POST: register ECDH public key (JWK) for a username
- /keys GET: list users with registered keys
- /keys/<user> GET: get user's public key
- /dm POST: relay encrypted DM blob to recipient
- SSE streams now register for DM delivery via name param
- Server never sees plaintext - only ciphertext passes through

Web UI:
- Auto-generates ECDH P-256 key pair on load (no setup needed)
- /dm @username message - sends E2E encrypted DM
- /users - list users with registered keys
- DMs shown with lock icon, pink color, direction arrows
- Decryption happens entirely in browser
- Key re-registered on name change
- Derived AES keys cached per peer

Protocol:
- ECDH key exchange: each client exports JWK public key
- Shared secret derived via ECDH P-256
- Messages encrypted with AES-256-GCM + random 12-byte nonce
- Ciphertext + nonce sent as base64 through server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:14:28 +04:00
Siavash Sameni
c97a3834d1 v12: group chat with optional passwords
- /group/<name> URL creates/joins a group (auto-created on first visit)
- / and /chat redirect to /group/lobby (default group)
- Each group has isolated history, clients, and SSE streams
- /setpass <password> sets a password for the current group
- /clearpass removes the password
- Password prompt modal in web UI, stored in sessionStorage
- SSE sends auth-fail event if wrong password, triggers re-prompt
- Group name shown as tag in header
- TCP clients use lobby group by default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:33:28 +04:00
Siavash Sameni
087334ffe9 v11: PWA support for mobile - installable app with offline fallback
- Web manifest with standalone display mode
- SVG chat bubble icon (no external assets needed)
- Service worker for install + offline page
- iOS meta tags: apple-mobile-web-app-capable, status bar style
- Mobile-optimized layout: safe-area insets, dvh units, rounded inputs
- Name input moved to header, file button + send in bottom bar
- 16px font on input (prevents iOS zoom)
- Name persisted to localStorage on mobile
- Keyboard-aware scroll (visualViewport resize listener)
- Install banner with prompt for Android Chrome

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:14:38 +04:00
2 changed files with 1262 additions and 140 deletions

554
DESIGN.md Normal file
View File

@@ -0,0 +1,554 @@
# Warzone Messenger — Design Document
## Problem Statement
Current chat.py has fundamental issues:
- **Identity is username-based** — users pick names, no cryptographic identity. Device change = lost keys = broken encryption.
- **No forward secrecy** — same ECDH key pair forever. Compromise one key, read all past messages.
- **No offline delivery** — if you're not connected, messages are lost.
- **Single server** — no federation, no redundancy. Server goes down = no chat.
- **Python** — too slow for real deployment, hard to distribute as a single binary.
## Design Goals
1. **Identity = key pair** — your identity IS your private key seed. No usernames, no accounts.
2. **Signal-grade encryption** — Double Ratchet for 1:1, Sender Keys for groups.
3. **Federation via DNS** — servers discover each other using TXT records, like Matrix but simpler.
4. **Warzone-grade delivery** — assumes intermittent connectivity, supports mule-based physical delivery.
5. **Single binary** — Rust, compiles to one static binary per platform.
6. **ntfy for push** — leverage existing notification infrastructure, no custom push servers.
---
## 1. Identity Model
### Seed-Based Identity
```
seed (32 bytes) → Ed25519 signing keypair + X25519 encryption keypair
```
- **Seed**: 32 random bytes, displayed as BIP39 mnemonic (24 words) for human backup
- **Signing key (Ed25519)**: signs messages, proves identity
- **Encryption key (X25519)**: used in key exchange for E2E encryption
- **Fingerprint**: `SHA-256(public_signing_key)[:16]` displayed as hex, e.g. `a3f8:c912:44be:7d01`
- **Display name**: optional, self-assigned, NOT part of identity. Can change anytime.
- **Address**: `fingerprint@server.example.com` — the full address includes the home server
### Key Storage
| Platform | Storage |
|----------|---------|
| CLI | `~/.warzone/identity.seed` (encrypted with passphrase via Argon2 + ChaCha20) |
| Browser | IndexedDB (non-extractable CryptoKey) + seed backup prompt on first run |
| Mobile (PWA) | Same as browser, seed shown as QR code for device transfer |
### Device Transfer
User scans QR code containing the seed (or types 24 words). New device derives the same keypair. Identity is portable — not tied to any server or device.
### Trust Model
First-use trust (TOFU) by default. Users can verify fingerprints out-of-band (QR code scan, read aloud). Verified contacts are pinned — if their key changes, you get a hard warning (not just a dismissible notice).
### Challenge: Username Squatting
Since identity is a fingerprint, not a name, there's no squatting. Display names are untrusted labels. The UI should prominently show fingerprints for new contacts and warn on display name collisions.
### Challenge: Key Loss
Seed IS identity. Lose seed = lose identity forever. Mitigations:
- BIP39 mnemonic backup (write on paper)
- Optional seed escrow to a trusted contact (Shamir's Secret Sharing, 2-of-3)
- Server never has seed — cannot help recover
---
## 2. Encryption Protocol
### 1:1 Messages — Signal Double Ratchet
```
Initial key exchange:
Alice's X25519 identity key + ephemeral key
Bob's X25519 identity key + signed pre-key + one-time pre-key
→ X3DH → shared secret
→ initialize Double Ratchet
Every message:
Symmetric ratchet step → new message key
Every N messages or on reply: DH ratchet step → new chain key
→ forward secrecy: compromise current key ≠ past messages readable
→ future secrecy: compromise current key, recovery after next DH ratchet
```
**Pre-key bundles**: each user uploads signed pre-keys to their home server. Other users fetch these to initiate sessions even when the recipient is offline.
### Group Messages — Sender Keys (Signal protocol for groups)
```
Each member generates a Sender Key (random symmetric key + chain)
Sender Key distributed to all group members via 1:1 encrypted channels
Messages encrypted with sender's Sender Key
Sender Key ratchets forward on each message
Member join: new Sender Keys distributed to everyone
Member leave: all members rotate their Sender Keys
```
### Challenge: Group Forward Secrecy
Sender Keys don't provide per-message forward secrecy like Double Ratchet. Trade-off: performance (one encrypt per message vs one per member). Acceptable for groups < 100. For larger groups, consider MLS (Message Layer Security, RFC 9420).
### Challenge: Multi-Device
Each device has its own X25519 keypair derived from the seed. Sender encrypts to all of recipient's known devices. Device list is signed by the identity key and published to the home server.
### Message Format
```
{
"v": 1,
"from": "a3f8c912...", // sender fingerprint
"to": "b7d1e845...", // recipient fingerprint (or group ID)
"ts": 1711443600,
"type": "msg", // msg | file | key_exchange | receipt
"session": "...", // Double Ratchet session ID
"ratchet": { "dh": "...", "n": 42, "pn": 41 },
"ciphertext": "base64...", // encrypted payload
"sig": "base64..." // Ed25519 signature over everything above
}
```
---
## 3. Federation via DNS
### Server Discovery
Each server operates under a domain. Federation is discovered via DNS TXT records:
```
_warzone._tcp.example.com TXT "v=wz1; endpoint=https://wz.example.com; pubkey=base64..."
```
Fields:
- `v=wz1` — protocol version
- `endpoint` — HTTPS URL of the server API
- `pubkey` — server's Ed25519 public key (for server-to-server auth)
### Server-to-Server Protocol
```
Server A wants to deliver message to user@example.com:
1. DNS lookup: _warzone._tcp.example.com → TXT record → endpoint URL
2. TLS connection to endpoint
3. Mutual authentication: both servers verify each other's pubkey
4. Deliver encrypted message blob (server cannot read it)
5. Recipient's server queues it for delivery
```
### Home Server Responsibilities
- Store and forward messages for its users
- Host pre-key bundles for key exchange
- Serve user's device list and public keys
- Relay messages to federated servers
- Queue messages for offline users
### Challenge: DNS Availability in Warzone
DNS may be unreliable or censored. Mitigations:
- **Hard-coded peer list**: users can manually add server endpoints
- **DNS-over-HTTPS (DoH)**: bypass local DNS censorship
- **mDNS/local discovery**: for LAN-only operation when internet is down
- **Gossip protocol**: servers share their known peer list with each other
### Challenge: Server Impersonation
Server pubkey in DNS TXT record prevents impersonation. But DNS itself could be hijacked. Mitigations:
- DNSSEC validation
- TOFU for server keys (pin on first contact)
- Certificate transparency-style log for server key changes
### Key Transparency via DNS
Use DNS as a decentralized public key registry — prevents the server from performing MITM attacks on key exchange.
Each user publishes their public key as a DNS TXT record, signed by their own identity key:
```
_wz._id.<hashed-fingerprint>.example.com TXT "v=wz1; fp=a3f8c912...; pubkey=base64...; sig=base64..."
```
- `fp` — full fingerprint
- `pubkey` — user's Ed25519 public identity key
- `sig` — self-signature over (fp + pubkey), proving the DNS record was authored by the key holder
**Verification flow:**
```
Bob wants Alice's key:
1. Ask server → server says Alice's key is X
2. DNS lookup → _wz._id.<hash(alice-fp)>.example.com → key is X, self-signed
3. Match? → trusted
4. Mismatch? → HARD WARNING: server may be performing MITM
5. No DNS record? → fall back to TOFU (trust on first use)
```
**Why DNS works here:**
- Decentralized: no single party controls all DNS (especially across domains)
- The self-signature in the TXT record means even the DNS admin can't forge it without Alice's private key
- DNSSEC adds transport integrity (record wasn't tampered in transit)
- Records are globally cached and replicated — hard to silently change
**Privacy concern:** public DNS means anyone can enumerate users by scanning TXT records. Mitigation: subdomain is `SHA-256(fingerprint)[:16]` — you must already know the fingerprint to look up the record. This makes enumeration impractical.
**Scalability:** one TXT record per user. Fine for thousands of users per domain. Large orgs can shard across subdomains.
**When users don't control DNS:** in an org deployment, the admin controls the DNS zone. The admin could collude with the server to MITM. But the self-signature still protects — the admin would need the user's private key to forge a valid record. The only attack is *deleting* the record (forcing TOFU fallback), not *replacing* it.
**Integration with federation:** the same DNS zone handles both server discovery (`_warzone._tcp`) and user key transparency (`_wz._id`). One DNS zone, two purposes.
---
## 4. Warzone Delivery — Mule Protocol
### Problem
In conflict zones, internet connectivity is intermittent, unreliable, or surveilled. Servers may be offline for hours or days. Traditional store-and-forward fails when both servers are rarely online simultaneously.
### Mule Role
A **mule** is a device (phone, laptop, USB drive) that physically carries messages between disconnected networks.
```
Network A (offline) Mule Network B (online)
| | |
|<-- connect to A -------| |
|-- queued messages ---->| |
|<-- delivery receipts --| |
| | |
| |--- travel physically ---->|
| | |
| |-- connect to B ---------->|
| |-- deliver messages ------>|
| |<-- queued for A ---------|
| |<-- receipts for A --------|
| | |
| |<-- travel back -----------|
| | |
|<-- connect to A -------| |
|<-- deliver from B -----| |
|-- receipts for B ----->| |
```
### Mule Protocol
1. **Authentication**: mule presents its identity (keypair). Server checks if mule is authorized (allowlist or signed authorization token from an admin).
2. **Pickup**: mule sends `PICKUP` request. Server gives all queued outbound messages (encrypted blobs — mule cannot read them). Server marks messages as "in transit by mule X".
3. **Delivery**: mule connects to destination server, sends `DELIVER` with the blobs. Destination server validates signatures and queues for recipients.
4. **Receipts**: destination server gives mule delivery receipts (signed). Mule carries these back.
5. **Receipt enforcement**: on next pickup, mule MUST present receipts for previous delivery. If no receipts → server refuses new pickup (prevents mule from dropping messages silently). Exception: mule can present a signed "delivery failed" report explaining why.
6. **Deduplication**: messages have unique IDs. Servers deduplicate on receive. Multiple mules can carry the same messages — first delivery wins, duplicates are silently dropped.
### Queue Management
```
Message states on origin server:
QUEUED → waiting for delivery (direct or mule)
IN_TRANSIT → picked up by mule X at time T
DELIVERED → receipt received
EXPIRED → TTL exceeded, dropped
TTL: configurable per-message (default 7 days)
Retry: if IN_TRANSIT for > 24h with no receipt, re-queue
```
### Challenge: Mule Compromise
Mule has encrypted blobs. Even if captured:
- Messages are E2E encrypted — mule sees only ciphertext
- Metadata (sender/recipient fingerprints) is visible to mule. Mitigation: wrap in an outer encryption layer to the destination server's public key, so mule only sees "blob for server X"
- Mule authorization can be revoked by server admin
### Challenge: Message Ordering
Mule delivery is inherently out-of-order. Messages carry sequence numbers per conversation. Clients reorder on display. Ratchet protocol handles out-of-order decryption natively (message keys are cached for skipped messages).
### Challenge: Mule Bandwidth
Mule may carry gigabytes of messages on a USB drive, or megabytes on a phone over Bluetooth. Protocol must support:
- Priority levels (urgent messages first)
- Compression (zstd on the blob bundle)
- Partial sync (resume interrupted transfer)
- Size limits per mule (server respects mule's capacity declaration)
---
## 5. Notification via ntfy
### Why ntfy
- Self-hostable, simple HTTP API
- Works on Android (no Google Play Services needed), iOS, desktop
- Supports E2E encryption (ntfy's own, separate from ours)
- Can be deployed alongside our server
### Integration
```
User registers ntfy topic: fingerprint-derived, e.g. wz_a3f8c912
Server pushes notification on new message:
POST https://ntfy.example.com/wz_a3f8c912
Body: "New message from <display_name>"
(NO message content — that's E2E encrypted)
```
User subscribes to their topic in ntfy app. Gets push notification, opens warzone client to read the actual message.
### Challenge: ntfy Metadata
ntfy server sees that a notification was sent to a topic (i.e., someone messaged this user). Mitigation: self-host ntfy on the same server. Or accept the metadata leak as a trade-off for push notification functionality.
---
## 6. Rust Rewrite
### Why Rust
- Single static binary (no runtime dependencies)
- Memory safety without GC
- Excellent async I/O (tokio)
- Cross-compile to Linux ARM (warzone routers, phones), Windows, macOS
- WebAssembly target for browser client
### Crate Selection
| Function | Crate |
|----------|-------|
| Async runtime | `tokio` |
| HTTP server | `axum` |
| Crypto | `ring` or `libsignal-protocol` |
| Signal protocol | `libsignal-protocol-rust` (Signal's official Rust impl) |
| Ed25519 | `ed25519-dalek` |
| X25519 | `x25519-dalek` |
| Argon2 | `argon2` |
| DNS | `trust-dns-resolver` |
| TLS | `rustls` |
| Database | `sled` (embedded) or `sqlite` via `rusqlite` |
| Serialization | `serde` + `bincode` (wire) + `serde_json` (API) |
| BIP39 | `bip39` |
| Compression | `zstd` |
| CLI | `clap` |
| TUI | `ratatui` |
### Binary Targets
```
warzone-server # server binary
warzone # CLI client + TUI
warzone-mule # mule binary (subset of client)
warzone.wasm # browser client (via wasm-pack)
```
### Architecture
```
┌─────────────────────────────────────────┐
│ Server │
├──────────┬──────────┬───────────────────┤
│ HTTP API │ WS relay │ Federation (S2S) │
├──────────┴──────────┴───────────────────┤
│ Message Router │
├──────────┬──────────┬───────────────────┤
│ Queue DB │ Key Store│ User Registry │
│ (sled) │ (sled) │ (sled) │
└──────────┴──────────┴───────────────────┘
┌─────────────────────────────────────────┐
│ Client │
├──────────┬──────────┬───────────────────┤
│ TUI │ Web(WASM)│ CLI │
├──────────┴──────────┴───────────────────┤
│ Protocol Layer │
├──────────┬──────────┬───────────────────┤
│ Signal │ Identity │ Storage │
│ Protocol │ Manager │ (sled/IndexedDB) │
└──────────┴──────────┴───────────────────┘
```
---
## 7. Roadmap
### Phase 0 — Current (Python prototype) ✅
- [x] Basic chat server + web UI
- [x] WebSocket SSH tunnel
- [x] Nginx reverse proxy + ArvanCloud deployment
- [x] ECDH + AES-GCM DMs (basic, no forward secrecy)
- [x] Group chat with passwords
- [x] PWA support
- [x] File upload
### Phase 1 — Identity & Crypto Foundation (Rust)
- [ ] Rust project scaffold (cargo workspace: server, client, protocol, mule)
- [ ] Seed-based identity (Ed25519 + X25519 from 32-byte seed)
- [ ] BIP39 mnemonic generation and recovery
- [ ] Seed encryption at rest (Argon2 + ChaCha20-Poly1305)
- [ ] Pre-key bundle generation and storage
- [ ] X3DH key exchange implementation
- [ ] Double Ratchet for 1:1 messaging
- [ ] Message signing (Ed25519)
- [ ] Basic server: accept connections, store-and-forward
### Phase 2 — Core Messaging
- [ ] 1:1 E2E encrypted messaging (full Signal protocol)
- [ ] Offline message queuing with TTL
- [ ] Multi-device support (device list signed by identity key)
- [ ] Sender Keys for group encryption
- [ ] Group management (create, invite, leave, kick)
- [ ] File transfer (chunked, encrypted)
- [ ] Delivery receipts (sent, delivered, read)
- [ ] Message ordering and deduplication
- [ ] TUI client (ratatui)
- [ ] Web client (WASM)
### Phase 3 — Federation & Key Transparency
- [ ] DNS TXT record format specification (server discovery + user key transparency)
- [ ] User self-signed key publication to DNS (`_wz._id.<hash>.domain`)
- [ ] Key verification: server response vs DNS record cross-check
- [ ] Server-to-server mutual TLS authentication
- [ ] Federated message delivery
- [ ] Server key pinning (TOFU)
- [ ] Federated pre-key bundle fetching
- [ ] Gossip-based peer discovery fallback
- [ ] Hard-coded peer list for DNS-free operation
### Phase 4 — Warzone Delivery
- [ ] Mule protocol specification
- [ ] Mule authentication and authorization
- [ ] Message pickup with capacity declaration
- [ ] Delivery receipt enforcement
- [ ] Outer encryption layer (hide metadata from mule)
- [ ] Bundle compression (zstd)
- [ ] Partial sync / resume
- [ ] Priority levels
- [ ] Mule CLI binary
### Phase 5 — Transport Fallbacks
- [ ] Bluetooth mule transfer (phone-to-phone, phone-to-server)
- [ ] LoRa transport layer (low bandwidth, long range, last-resort)
- [ ] mDNS / LAN discovery for local mesh
- [ ] Wi-Fi Direct for nearby device sync
### Phase 6 — Metadata Protection (Optional Layer)
- [ ] Onion routing between federated servers (opt-in, requires good connectivity)
- [ ] Padding and traffic shaping to resist traffic analysis
- [ ] Sealed sender (server doesn't know who sent a message, only who receives)
### Phase 7 — Polish & Operations
- [ ] ntfy integration for push notifications
- [ ] DoH for DNS resolution in censored networks
- [ ] Admin CLI (manage users, mules, federation)
- [ ] Monitoring and health checks
- [ ] Rate limiting and abuse prevention
- [ ] Audit logging
- [ ] Server-at-rest encryption (optional, manual key on boot)
- [ ] Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
- [ ] Documentation and protocol specification
---
## Resolved Decisions
| Question | Decision | Rationale |
|----------|----------|-----------|
| MLS vs Sender Keys | **Sender Keys** (groups ≤ 50) | Simpler, sufficient for target group sizes. MLS revisited if needed later. |
| Metadata protection | **Optional onion layer** | Opt-in when connectivity allows. Not a blocker for core functionality. Sealed sender as a lighter alternative first. |
| Deniability | **Deniability by default** (Signal model) | Safety-first for users in hostile environments. Non-repudiation can be added as opt-in per-conversation later. |
| Server-at-rest encryption | **Optional, not in core** | Nice to have. Implement as a flag: `--encrypt-db` with passphrase on boot. E2E already protects message content. |
| Incentives / tokenization | **Not in scope** | This is an organizational/military tool. Participants cooperate by mandate, not incentive. |
| Transport fallbacks | **Bluetooth + LoRa** | Mules use Bluetooth for device-to-device. LoRa for extreme last-resort (low bandwidth but km range). LoRa is not Phase 1. |
| Key transparency | **DNS TXT records** | Each user self-signs their pubkey in a DNS TXT record. Server can't MITM because it can't forge the self-signature. Integrated with federation DNS in Phase 3. |
| Multi-device ratchet | **Per-device sessions** | Each device maintains its own Double Ratchet session with each contact (Signal's approach). Cross-device history sync via encrypted device-to-device channel using shared seed. |
## Open Questions
1. **LoRa investment**: LoRa has ~250 byte payload limit. Emergency-only (receipts + short text) or a real feature? Not Phase 1 either way — but the compact binary format should be designed early so the message layer doesn't assume JSON everywhere.
2. **Legal**: E2E encryption with mule delivery designed for warzone use has significant legal implications in many jurisdictions. Needs legal review before deployment.
3. **Sealed sender vs onion routing**: Sealed sender (Signal's approach — server knows recipient but not sender) is lighter than full onion routing. Plan: sealed sender first as the default metadata protection, full onion routing as Phase 6 upgrade for when connectivity allows it.
---
## 8. Transport Layer Architecture
The protocol is transport-agnostic. The message format is the same regardless of how it travels. Transports are pluggable:
```
┌─────────────────────────────────────────────┐
│ Application Layer │
│ (Signal Protocol, Message Routing, Queue) │
├─────────────────────────────────────────────┤
│ Transport Abstraction │
│ trait Transport { │
│ async fn send(&self, endpoint, blob); │
│ async fn recv(&self) -> blob; │
│ } │
├──────┬──────┬──────┬──────┬────────┬────────┤
│ HTTPS│ WS │ BT │ LoRa │Wi-Fi │ USB │
│ │ │ │ │Direct │ (file) │
└──────┴──────┴──────┴──────┴────────┴────────┘
```
### HTTPS (Primary)
- Standard server-to-server and client-to-server
- TLS 1.3, certificate pinning
- HTTP/2 for multiplexing
- SSE or WebSocket for real-time push
### Bluetooth (Mule + Nearby)
- BLE for discovery, Bluetooth Classic for data transfer
- Range: ~10-100m
- Bandwidth: ~2 Mbps practical
- Use case: mule syncs with server/client in proximity
- Protocol: RFCOMM socket, same message blobs as HTTPS
### LoRa (Last Resort)
- Range: 2-15 km (line of sight), 1-5 km urban
- Bandwidth: 0.3-50 kbps
- Payload: ~250 bytes per packet
- Use case: delivery receipts, short text, presence beacons
- NOT for files or media — text-only, heavily compressed
- Message format: compact binary (not JSON)
```
LoRa packet (250 bytes max):
[1] version
[1] type (text=0x01, receipt=0x02, beacon=0x03)
[8] sender fingerprint (truncated)
[8] recipient fingerprint (truncated)
[4] timestamp (unix, 32-bit)
[12] nonce
[~216] ciphertext (AES-GCM, ~200 chars of text)
```
### Wi-Fi Direct (Nearby Mesh)
- Range: ~200m
- Bandwidth: ~250 Mbps
- Use case: local group sync when no internet, ad-hoc mesh
- Devices form a local group, sync message queues peer-to-peer
### USB / File (Sneakernet)
- Export message queue to encrypted file
- Copy to USB drive
- Import on destination machine
- Same as mule protocol but manual file transfer
- `warzone export --since 24h --to /mnt/usb/messages.wz`
- `warzone import /mnt/usb/messages.wz`

710
chat.py
View File

@@ -25,7 +25,7 @@ import html
import urllib.parse import urllib.parse
PORT = 9999 PORT = 9999
VERSION = "10" VERSION = "14"
TUNNEL_TARGETS = { TUNNEL_TARGETS = {
"parspack": ("185.208.174.152", 22), "parspack": ("185.208.174.152", 22),
"mequ": ("188.213.68.133", 2022), "mequ": ("188.213.68.133", 2022),
@@ -36,9 +36,32 @@ MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
# ── Server ────────────────────────────────────────────────────────────── # ── Server ──────────────────────────────────────────────────────────────
clients: dict[asyncio.StreamWriter, str] = {} # TCP clients # E2E encryption: public key registry (username -> JWK public key JSON string)
sse_queues: list[asyncio.Queue] = [] # web clients KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "keys.json")
history: list[dict] = []
def load_keys() -> dict[str, str]:
"""Load user keys from disk."""
try:
with open(KEYS_FILE, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save_keys():
"""Persist user keys to disk."""
try:
with open(KEYS_FILE, "w") as f:
json.dump(user_keys, f)
except Exception:
pass
user_keys: dict[str, str] = load_keys()
# DM routing: username -> list of queues for SSE delivery
dm_targets: dict[str, list] = {}
uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes (insertion order) uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes (insertion order)
total_file_bytes = 0 total_file_bytes = 0
@@ -53,31 +76,52 @@ def store_file(file_id: str, data: bytes):
total_file_bytes -= len(uploaded_files.pop(oldest_id)) total_file_bytes -= len(uploaded_files.pop(oldest_id))
class Group:
"""A chat group with its own history, clients, and optional password."""
def __init__(self, name: str):
self.name = name
self.password: str | None = None
self.history: list[dict] = []
self.clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
self.sse_queues: list[asyncio.Queue] = []
async def broadcast(msg: dict): async def broadcast(self, msg: dict):
history.append(msg) self.history.append(msg)
line = json.dumps(msg) + "\n" line = json.dumps(msg) + "\n"
# TCP clients
dead = [] dead = []
for w in clients: for w in self.clients:
try: try:
w.write(line.encode()) w.write(line.encode())
await w.drain() await w.drain()
except Exception: except Exception:
dead.append(w) dead.append(w)
for w in dead: for w in dead:
clients.pop(w, None) self.clients.pop(w, None)
# SSE web clients
dead_q = [] dead_q = []
for q in sse_queues: for q in self.sse_queues:
try: try:
q.put_nowait(msg) q.put_nowait(msg)
except Exception: except Exception:
dead_q.append(q) dead_q.append(q)
for q in dead_q: for q in dead_q:
sse_queues.remove(q) self.sse_queues.remove(q)
# All groups keyed by name. Auto-created on first access.
groups: dict[str, Group] = {}
DEFAULT_GROUP = "lobby"
def get_group(name: str) -> Group:
"""Get or create a group by name."""
name = name.lower().strip()
if not name:
name = DEFAULT_GROUP
if name not in groups:
groups[name] = Group(name)
return groups[name]
# ── HTML / JS chat page ──────────────────────────────────────────────── # ── HTML / JS chat page ────────────────────────────────────────────────
@@ -86,62 +130,123 @@ CHAT_HTML = r"""<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Chat">
<meta name="theme-color" content="#1a1a2e">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="/manifest.json">
<title>Chat</title> <title>Chat</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Courier New', monospace; html, body { height: 100%; overflow: hidden; }
display: flex; flex-direction: column; height: 100vh; } body { background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, 'Courier New', monospace;
#messages { flex: 1; overflow-y: auto; padding: 12px; } display: flex; flex-direction: column; height: 100vh; height: 100dvh;
.msg { padding: 3px 0; white-space: pre-wrap; word-wrap: break-word; } padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
#messages { flex: 1; overflow-y: auto; padding: 10px; -webkit-overflow-scrolling: touch; }
.msg { padding: 3px 0; white-space: pre-wrap; word-wrap: break-word; font-size: 14px; }
.msg code { background: #2a2a4a; padding: 1px 5px; border-radius: 3px; color: #f8c555; } .msg code { background: #2a2a4a; padding: 1px 5px; border-radius: 3px; color: #f8c555; }
.msg pre { background: #12122a; border: 1px solid #333; border-radius: 4px; .msg pre { background: #12122a; border: 1px solid #333; border-radius: 4px;
padding: 8px; margin: 4px 0; overflow-x: auto; } padding: 8px; margin: 4px 0; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.msg pre code { background: none; padding: 0; color: #e0e0e0; } .msg pre code { background: none; padding: 0; color: #e0e0e0; }
.msg strong { color: #fff; } .msg strong { color: #fff; }
.msg em { color: #ccc; } .msg em { color: #ccc; }
.msg a.auto-link { color: #67c7eb; } .msg a.auto-link { color: #67c7eb; }
.ts { color: #666; } .ts { color: #666; }
.sys { color: #5e9ca0; font-style: italic; } .sys { color: #5e9ca0; font-style: italic; }
.dm-hint { color: #555; font-size: 0.8em; display: block; margin-top: 2px; }
.file-link { display: inline-block; background: #0f3460; border: 1px solid #444; .file-link { display: inline-block; background: #0f3460; border: 1px solid #444;
padding: 4px 10px; border-radius: 4px; margin: 2px 0; color: #67c7eb; padding: 6px 12px; border-radius: 4px; margin: 2px 0; color: #67c7eb;
text-decoration: none; } text-decoration: none; }
.file-link:hover { background: #1a4a80; } .file-link:hover, .file-link:active { background: #1a4a80; }
#bottom { display: flex; padding: 8px; gap: 8px; border-top: 1px solid #333; #header { display: flex; padding: 6px 10px; gap: 6px; background: #16213e;
border-bottom: 1px solid #333; align-items: center; }
#name { flex: 0 0 auto; width: 80px; padding: 8px; background: #0f3460; border: 1px solid #444;
color: #e0e0e0; border-radius: 4px; font-size: 14px; }
#header-info { flex: 1; font-size: 0.7em; text-align: right; color: #8899aa; }
#header-info code { background: #0f3460; color: #67c7eb; padding: 1px 5px; border-radius: 3px;
font-size: 1em; }
#bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #333;
background: #16213e; align-items: flex-end; } background: #16213e; align-items: flex-end; }
#name { width: 100px; padding: 8px; background: #0f3460; border: 1px solid #444; #input { flex: 1; padding: 10px; background: #0f3460; border: 1px solid #444;
color: #e0e0e0; border-radius: 4px; align-self: flex-end; } color: #e0e0e0; border-radius: 20px; resize: none; min-height: 40px;
#input { flex: 1; padding: 8px; background: #0f3460; border: 1px solid #444; max-height: 120px; font-family: inherit; font-size: 16px; line-height: 1.4; }
color: #e0e0e0; border-radius: 4px; resize: none; min-height: 38px; #send { padding: 10px 16px; background: #e94560; border: none; color: #fff;
max-height: 200px; font-family: inherit; font-size: inherit; line-height: 1.4; } border-radius: 20px; cursor: pointer; align-self: flex-end; font-size: 14px;
#send { padding: 8px 16px; background: #e94560; border: none; color: #fff; min-height: 40px; }
border-radius: 4px; cursor: pointer; align-self: flex-end; } #send:hover, #send:active { background: #c73e54; }
#send:hover { background: #c73e54; } #file-btn { padding: 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0;
#file-btn { padding: 8px 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0; border-radius: 50%; cursor: pointer; align-self: flex-end; font-size: 1.1em;
border-radius: 4px; cursor: pointer; align-self: flex-end; font-size: 1.1em; } min-width: 40px; min-height: 40px; text-align: center; line-height: 20px; }
#file-btn:hover { background: #1a4a80; } #file-btn:hover, #file-btn:active { background: #1a4a80; }
#file-input { display: none; } #file-input { display: none; }
.hint { color: #555; font-size: 0.75em; padding: 2px 12px; } #install-bar { display: none; padding: 8px 12px; background: #0f3460; text-align: center;
border-bottom: 1px solid #333; }
#install-bar button { background: #e94560; border: none; color: #fff; padding: 6px 16px;
border-radius: 4px; cursor: pointer; margin: 0 4px; }
#install-bar .dismiss { background: transparent; color: #666; }
#pw-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.8);
z-index:100; align-items:center; justify-content:center; }
#pw-overlay.show { display:flex; }
#pw-box { background:#16213e; border:1px solid #444; border-radius:8px; padding:24px;
text-align:center; max-width:300px; width:90%; }
#pw-box h3 { margin-bottom:12px; color:#e0e0e0; }
#pw-box input { width:100%; padding:10px; background:#0f3460; border:1px solid #444;
color:#e0e0e0; border-radius:4px; margin-bottom:10px; font-size:16px; }
#pw-box button { padding:8px 20px; background:#e94560; border:none; color:#fff;
border-radius:4px; cursor:pointer; }
#pw-box .pw-err { color:#e94560; font-size:0.85em; margin-bottom:8px; display:none; }
#group-tag { background:#e94560; color:#fff; padding:2px 8px; border-radius:10px;
font-size:0.75em; margin-left:6px; }
@media (max-width: 500px) {
.msg { font-size: 13px; }
.ts { font-size: 11px; }
#input { font-size: 16px; }
}
</style> </style>
</head> </head>
<body> <body>
<div id="install-bar">
Install as app for notifications &amp; fullscreen
<button id="install-btn">Install</button>
<button class="dismiss" id="install-dismiss">Later</button>
</div>
<div id="pw-overlay">
<div id="pw-box">
<h3>This group is password protected</h3>
<div class="pw-err" id="pw-err">Wrong password</div>
<input type="password" id="pw-input" placeholder="Enter password…" autocomplete="off">
<button id="pw-btn">Join</button>
</div>
</div>
<div id="header">
<input id="name" placeholder="Name" value="" autocomplete="off">
<span id="group-tag"></span>
<span id="header-info"><code>/dm @user msg</code> <code>/r reply</code> <code>/users</code> <code>/setpass</code> <code>/color</code></span>
</div>
<div id="messages"></div> <div id="messages"></div>
<div class="hint">Shift+Enter for newline · Enter to send</div>
<div id="bottom"> <div id="bottom">
<input id="name" placeholder="Name" value=""> <label id="file-btn" title="Upload file">&#128206;<input type="file" id="file-input"></label>
<textarea id="input" placeholder="Type a message…" rows="1" autofocus <textarea id="input" placeholder="Type a message…" rows="1" autofocus
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea> autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
<label id="file-btn" title="Upload file">&#128206;<input type="file" id="file-input"></label> <button id="send">&#9654;</button>
<button id="send">Send</button>
</div> </div>
<script> <script>
const GROUP = '%%GROUP%%';
const HAS_PASSWORD = %%HAS_PASSWORD%%;
const BASE = '/group/' + GROUP;
const $msg = document.getElementById('messages'); const $msg = document.getElementById('messages');
const $input = document.getElementById('input'); const $input = document.getElementById('input');
const $name = document.getElementById('name'); const $name = document.getElementById('name');
const $send = document.getElementById('send'); const $send = document.getElementById('send');
const $file = document.getElementById('file-input'); const $file = document.getElementById('file-input');
$name.value = 'user' + Math.floor(Math.random() * 1000); document.getElementById('group-tag').textContent = GROUP;
document.title = GROUP + ' - Chat';
$name.value = localStorage.getItem('chat-name') || ('user' + Math.floor(Math.random() * 1000));
const USER_COLORS = [ const USER_COLORS = [
'#e6a23c', '#f56c9d', '#67c7eb', '#b39ddb', '#e6a23c', '#f56c9d', '#67c7eb', '#b39ddb',
@@ -226,15 +331,64 @@ function addMsg(data) {
function send() { function send() {
const text = $input.value.trimEnd(); const text = $input.value.trimEnd();
const name = $name.value.trim() || 'anon'; const name = $name.value.trim() || 'anon';
localStorage.setItem('chat-name', name);
if (!text) return; if (!text) return;
// Local commands // Local commands
const dmMatch = text.match(/^\/dm\s+@?(\S+)\s+([\s\S]+)/);
if (dmMatch) {
lastDmPeer = dmMatch[1];
encryptAndSendDM(dmMatch[1], dmMatch[2]);
$input.value = '';
$input.style.height = 'auto';
return;
}
const replyMatch = text.match(/^\/(?:reply|r)\s+([\s\S]+)/);
if (replyMatch) {
if (!lastDmPeer) {
addMsg({ts:Date.now()/1000, user:'***', text:'No one to reply to. Use /dm @user first.'});
} else {
encryptAndSendDM(lastDmPeer, replyMatch[1]);
}
$input.value = '';
$input.style.height = 'auto';
return;
}
if (text === '/users' || text === '/online') {
fetch('/keys').then(r => r.json()).then(users => {
addMsg({ts:Date.now()/1000, user:'***', text:'Users with keys: ' + users.join(', ')});
});
$input.value = '';
$input.style.height = 'auto';
return;
}
if (text === '/colors' || text === '/color') { if (text === '/colors' || text === '/color') {
reshuffleColors(); reshuffleColors();
$input.value = ''; $input.value = '';
$input.style.height = 'auto'; $input.style.height = 'auto';
return; return;
} }
fetch('/chat/send', { if (text.startsWith('/setpass ')) {
const pw = text.substring(9).trim();
fetch(BASE + '/setpass', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'password=' + encodeURIComponent(pw)
}).then(() => addMsg({ts:Date.now()/1000, user:'***', text:'Password set for this group.'}));
$input.value = '';
$input.style.height = 'auto';
return;
}
if (text === '/clearpass') {
fetch(BASE + '/setpass', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'password='
}).then(() => addMsg({ts:Date.now()/1000, user:'***', text:'Password cleared.'}));
$input.value = '';
$input.style.height = 'auto';
return;
}
fetch(BASE + '/send', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=' + encodeURIComponent(name) + '&text=' + encodeURIComponent(text) body: 'name=' + encodeURIComponent(name) + '&text=' + encodeURIComponent(text)
@@ -265,7 +419,7 @@ $file.onchange = function() {
const fd = new FormData(); const fd = new FormData();
fd.append('name', name); fd.append('name', name);
fd.append('file', f); fd.append('file', f);
fetch('/chat/upload', { method: 'POST', body: fd }); fetch(BASE + '/upload', { method: 'POST', body: fd });
this.value = ''; this.value = '';
}; };
@@ -304,19 +458,273 @@ window.addEventListener('focus', function() {
document.title = baseTitle; document.title = baseTitle;
}); });
// SSE // Password gate
const es = new EventSource('/chat/events'); let sessionPass = sessionStorage.getItem('pw-' + GROUP) || '';
es.onmessage = function(e) {
function startSSE() {
const myName = $name.value.trim() || 'anon';
const params = new URLSearchParams();
if (sessionPass) params.set('password', sessionPass);
params.set('name', myName);
const url = BASE + '/events?' + params.toString();
const es = new EventSource(url);
es.onmessage = function(e) {
// Check for auth error
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (data._auth === 'fail') {
es.close();
showPasswordPrompt();
return;
}
if (data.dm && data.encrypted) {
handleEncryptedDM(data);
} else {
addMsg(data); addMsg(data);
}
notify(data); notify(data);
};
es.onerror = function() {
addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'});
};
}
function showPasswordPrompt() {
document.getElementById('pw-overlay').classList.add('show');
document.getElementById('pw-input').focus();
}
document.getElementById('pw-btn').onclick = function() {
sessionPass = document.getElementById('pw-input').value;
sessionStorage.setItem('pw-' + GROUP, sessionPass);
document.getElementById('pw-overlay').classList.remove('show');
startSSE();
}; };
es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); }; document.getElementById('pw-input').onkeydown = function(e) {
if (e.key === 'Enter') document.getElementById('pw-btn').click();
};
// ── E2E Encrypted DMs (ECDH + AES-256-GCM via Web Crypto) ──
let myKeyPair = null;
let lastDmPeer = null; // for /reply
let myPubJwk = null;
const derivedKeys = {}; // cache: username -> CryptoKey (AES)
async function initCrypto() {
// Try to load persisted keys from localStorage
const savedPriv = localStorage.getItem('chat-key-priv');
const savedPub = localStorage.getItem('chat-key-pub');
if (savedPriv && savedPub) {
try {
const privJwk = JSON.parse(savedPriv);
myPubJwk = JSON.parse(savedPub);
const privKey = await crypto.subtle.importKey(
'jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
);
const pubKey = await crypto.subtle.importKey(
'jwk', myPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, []
);
myKeyPair = { privateKey: privKey, publicKey: pubKey };
} catch(e) {
// Corrupted keys, regenerate
localStorage.removeItem('chat-key-priv');
localStorage.removeItem('chat-key-pub');
return initCrypto();
}
} else {
// Generate new keys and persist
myKeyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
);
myPubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
const privJwk = await crypto.subtle.exportKey('jwk', myKeyPair.privateKey);
localStorage.setItem('chat-key-priv', JSON.stringify(privJwk));
localStorage.setItem('chat-key-pub', JSON.stringify(myPubJwk));
}
// Always register public key with server (re-registers on reconnect)
const myName = $name.value.trim() || 'anon';
fetch('/keys', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=' + encodeURIComponent(myName) + '&key=' + encodeURIComponent(JSON.stringify(myPubJwk))
});
}
// Re-register key when name changes
$name.addEventListener('change', function() {
localStorage.setItem('chat-name', this.value);
if (myPubJwk) {
fetch('/keys', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=' + encodeURIComponent(this.value.trim()) + '&key=' + encodeURIComponent(JSON.stringify(myPubJwk))
});
}
});
async function deriveAESKey(theirPubJwk) {
const theirPub = await crypto.subtle.importKey(
'jwk', theirPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []
);
const bits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: theirPub }, myKeyPair.privateKey, 256
);
return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt', 'decrypt']);
}
async function getAESKey(username) {
if (derivedKeys[username]) return derivedKeys[username];
const resp = await fetch('/keys/' + encodeURIComponent(username));
if (!resp.ok) return null;
const jwk = JSON.parse(await resp.text());
const key = await deriveAESKey(jwk);
derivedKeys[username] = key;
return key;
}
async function encryptAndSendDM(recipient, plaintext) {
const aesKey = await getAESKey(recipient);
if (!aesKey) {
addMsg({ts: Date.now()/1000, user: '***', text: 'User "' + recipient + '" has no key registered. They must be online.'});
return;
}
const nonce = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce }, aesKey, new TextEncoder().encode(plaintext)
);
const myName = $name.value.trim() || 'anon';
fetch('/dm', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'from=' + encodeURIComponent(myName)
+ '&to=' + encodeURIComponent(recipient)
+ '&encrypted=' + encodeURIComponent(btoa(String.fromCharCode(...new Uint8Array(enc))))
+ '&nonce=' + encodeURIComponent(btoa(String.fromCharCode(...nonce)))
});
}
async function handleEncryptedDM(data) {
const myName = $name.value.trim();
// Only decrypt if we are sender or recipient
if (data.to !== myName && data.user !== myName) return;
const otherUser = data.user === myName ? data.to : data.user;
// Update reply target when someone DMs us
if (data.user !== myName) lastDmPeer = data.user;
try {
const aesKey = await getAESKey(otherUser);
if (!aesKey) throw new Error('no key');
const ciphertext = Uint8Array.from(atob(data.encrypted), c => c.charCodeAt(0));
const nonce = Uint8Array.from(atob(data.nonce), c => c.charCodeAt(0));
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ciphertext);
const text = new TextDecoder().decode(plain);
const d = document.createElement('div');
d.className = 'msg';
const t = new Date(data.ts * 1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const arrow = data.user === myName ? '' + esc(data.to) : '' + esc(data.user);
const replyTo = data.user === myName ? data.to : data.user;
d.innerHTML = '<span class="ts">' + t + '</span> <span class="dm-tag" data-reply="' + esc(replyTo) + '" style="color:#ff6b9d;cursor:pointer" title="Click to reply">&#128274; DM ' + arrow + '</span>: ' + renderMd(text);
d.querySelector('.dm-tag').onclick = function() {
$input.value = '/dm @' + this.dataset.reply + ' ';
$input.focus();
};
$msg.appendChild(d);
$msg.scrollTop = $msg.scrollHeight;
} catch(e) {
const d = document.createElement('div');
d.className = 'msg';
const replyTo = data.user;
const t = new Date(data.ts * 1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
d.innerHTML = '<span class="ts">' + t + '</span> <span class="dm-tag" data-reply="' + esc(replyTo) + '" style="color:#ff6b9d;cursor:pointer" title="Click to reply">&#128274; DM from ' + esc(data.user) + '</span>: <em>[cannot decrypt]</em>';
d.querySelector('.dm-tag').onclick = function() {
$input.value = '/dm @' + this.dataset.reply + ' ';
$input.focus();
};
$msg.appendChild(d);
$msg.scrollTop = $msg.scrollHeight;
}
}
initCrypto();
// Check password requirement and start
if (HAS_PASSWORD && !sessionPass) {
showPasswordPrompt();
} else {
startSSE();
}
// PWA install prompt
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPrompt = e;
document.getElementById('install-bar').style.display = 'block';
});
document.getElementById('install-btn').onclick = function() {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function() { deferredPrompt = null; });
}
document.getElementById('install-bar').style.display = 'none';
};
document.getElementById('install-dismiss').onclick = function() {
document.getElementById('install-bar').style.display = 'none';
};
// Service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){});
}
// Mobile
window.visualViewport && window.visualViewport.addEventListener('resize', function() {
$msg.scrollTop = $msg.scrollHeight;
});
</script> </script>
</body> </body>
</html> </html>
""" """
PWA_MANIFEST = json.dumps({
"name": "Chat",
"short_name": "Chat",
"description": "Minimal multi-user chat",
"start_url": "/chat",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e",
"icons": [
{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}
]
})
# Minimal SVG icon (chat bubble)
PWA_ICON = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="100" fill="#1a1a2e"/>
<path d="M128 140h256c22 0 40 18 40 40v152c0 22-18 40-40 40H210l-70 52v-52h-12c-22 0-40-18-40-40V180c0-22 18-40 40-40z" fill="#e94560"/>
<circle cx="200" cy="256" r="18" fill="#fff"/>
<circle cx="256" cy="256" r="18" fill="#fff"/>
<circle cx="312" cy="256" r="18" fill="#fff"/>
</svg>"""
SERVICE_WORKER = """
const CACHE = 'chat-v1';
self.addEventListener('install', e => { self.skipWaiting(); });
self.addEventListener('activate', e => { e.waitUntil(clients.claim()); });
self.addEventListener('fetch', e => {
// Let all requests go to network (chat is real-time, caching would break it)
// But cache the shell for offline "you're offline" experience
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).catch(() => new Response(
'<html><body style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh"><h2>Offline - connect to the internet</h2></body></html>',
{headers: {'Content-Type': 'text/html'}}
))
);
}
});
"""
# ── Multipart parser (minimal, for file uploads) ─────────────────────── # ── Multipart parser (minimal, for file uploads) ───────────────────────
def parse_multipart(body: bytes, boundary: str) -> dict: def parse_multipart(body: bytes, boundary: str) -> dict:
@@ -455,6 +863,40 @@ async def handle_ws_tunnel(ws_reader, ws_writer, target):
async def handle_http(reader, writer, first_line): async def handle_http(reader, writer, first_line):
method, path, headers, body = await parse_http_request(reader, first_line) method, path, headers, body = await parse_http_request(reader, first_line)
# PWA assets
if method == "GET" and path == "/manifest.json":
resp = PWA_MANIFEST.encode()
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: application/manifest+json\r\n")
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
writer.write(b"\r\n")
writer.write(resp)
await writer.drain()
writer.close()
return
if method == "GET" and path == "/icon.svg":
resp = PWA_ICON.encode()
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: image/svg+xml\r\n")
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
writer.write(b"\r\n")
writer.write(resp)
await writer.drain()
writer.close()
return
if method == "GET" and path == "/sw.js":
resp = SERVICE_WORKER.encode()
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: application/javascript\r\n")
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
writer.write(b"\r\n")
writer.write(resp)
await writer.drain()
writer.close()
return
# GET /version # GET /version
if method == "GET" and path == "/version": if method == "GET" and path == "/version":
resp = json.dumps({"version": VERSION}).encode() resp = json.dumps({"version": VERSION}).encode()
@@ -487,9 +929,24 @@ async def handle_http(reader, writer, first_line):
writer.close() writer.close()
return return
# GET / or /chat — serve the web UI # GET / or /chat → redirect to /group/lobby
if method == "GET" and path in ("/", "/chat", "/chat/"): if method == "GET" and path in ("/", "/chat", "/chat/"):
resp = CHAT_HTML.encode() writer.write(b"HTTP/1.1 302 Found\r\nLocation: /group/lobby\r\nContent-Length: 0\r\n\r\n")
await writer.drain()
writer.close()
return
# ── Group routes: /group/<name>[/action] ──
if path.startswith("/group/"):
parts = path[7:].strip("/").split("/", 1) # strip "/group/"
group_name = urllib.parse.unquote(parts[0]) if parts[0] else DEFAULT_GROUP
action = parts[1] if len(parts) > 1 else ""
grp = get_group(group_name)
# GET /group/<name> — serve the web UI
if method == "GET" and action == "":
has_pw = "true" if grp.password else "false"
resp = CHAT_HTML.replace("%%GROUP%%", group_name).replace("%%HAS_PASSWORD%%", has_pw).encode()
writer.write(b"HTTP/1.1 200 OK\r\n") writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: text/html; charset=utf-8\r\n") writer.write(b"Content-Type: text/html; charset=utf-8\r\n")
writer.write(f"Content-Length: {len(resp)}\r\n".encode()) writer.write(f"Content-Length: {len(resp)}\r\n".encode())
@@ -499,8 +956,25 @@ async def handle_http(reader, writer, first_line):
writer.close() writer.close()
return return
# GET /chat/events — SSE stream # GET /group/<name>/events — SSE stream
if method == "GET" and path == "/chat/events": if method == "GET" and action.startswith("events"):
# Check password
query = ""
if "?" in action:
query = action.split("?", 1)[1]
qs = urllib.parse.parse_qs(query)
pw = qs.get("password", [""])[0]
if grp.password and pw != grp.password:
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: text/event-stream\r\n")
writer.write(b"Cache-Control: no-cache\r\n")
writer.write(b"X-Accel-Buffering: no\r\n")
writer.write(b"\r\n")
writer.write(f"data: {json.dumps({'_auth': 'fail'})}\n\n".encode())
await writer.drain()
writer.close()
return
writer.write(b"HTTP/1.1 200 OK\r\n") writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: text/event-stream\r\n") writer.write(b"Content-Type: text/event-stream\r\n")
writer.write(b"Cache-Control: no-cache\r\n") writer.write(b"Cache-Control: no-cache\r\n")
@@ -510,12 +984,16 @@ async def handle_http(reader, writer, first_line):
writer.write(b"\r\n") writer.write(b"\r\n")
await writer.drain() await writer.drain()
for msg in history: for msg in grp.history:
writer.write(f"data: {json.dumps(msg)}\n\n".encode()) writer.write(f"data: {json.dumps(msg)}\n\n".encode())
await writer.drain() await writer.drain()
q: asyncio.Queue = asyncio.Queue() q: asyncio.Queue = asyncio.Queue()
sse_queues.append(q) grp.sse_queues.append(q)
# Register for DM delivery
dm_name = qs.get("name", [""])[0]
if dm_name:
dm_targets.setdefault(dm_name, []).append(q)
try: try:
while True: while True:
msg = await q.get() msg = await q.get()
@@ -524,25 +1002,32 @@ async def handle_http(reader, writer, first_line):
except Exception: except Exception:
pass pass
finally: finally:
if q in sse_queues: if q in grp.sse_queues:
sse_queues.remove(q) grp.sse_queues.remove(q)
if dm_name and dm_name in dm_targets:
try:
dm_targets[dm_name].remove(q)
except ValueError:
pass
if not dm_targets[dm_name]:
del dm_targets[dm_name]
writer.close() writer.close()
return return
# POST /chat/send — send a message (supports multiline via JSON text field) # POST /group/<name>/send
if method == "POST" and path == "/chat/send": if method == "POST" and action == "send":
params = urllib.parse.parse_qs(body.decode()) params = urllib.parse.parse_qs(body.decode())
name = params.get("name", ["anon"])[0] name = params.get("name", ["anon"])[0]
text = params.get("text", [""])[0].strip() text = params.get("text", [""])[0].strip()
if text: if text:
await broadcast({"ts": time.time(), "user": name, "text": text}) await grp.broadcast({"ts": time.time(), "user": name, "text": text})
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n") writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain() await writer.drain()
writer.close() writer.close()
return return
# POST /chat/upload — file upload (multipart/form-data) # POST /group/<name>/upload
if method == "POST" and path == "/chat/upload": if method == "POST" and action == "upload":
ct = headers.get("content-type", "") ct = headers.get("content-type", "")
if "multipart/form-data" in ct and "boundary=" in ct: if "multipart/form-data" in ct and "boundary=" in ct:
boundary = ct.split("boundary=")[1].strip() boundary = ct.split("boundary=")[1].strip()
@@ -557,7 +1042,7 @@ async def handle_http(reader, writer, first_line):
if len(file_data) <= MAX_FILE_SIZE: if len(file_data) <= MAX_FILE_SIZE:
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16] file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
store_file(file_id, file_data) store_file(file_id, file_data)
await broadcast({ await grp.broadcast({
"ts": time.time(), "user": name, "ts": time.time(), "user": name,
"text": f"[file: {filename}]", "text": f"[file: {filename}]",
"file_id": file_id, "filename": filename, "file_id": file_id, "filename": filename,
@@ -568,6 +1053,90 @@ async def handle_http(reader, writer, first_line):
writer.close() writer.close()
return return
# POST /group/<name>/setpass
if method == "POST" and action == "setpass":
params = urllib.parse.parse_qs(body.decode(), keep_blank_values=True)
pw = params.get("password", [""])[0].strip()
grp.password = pw if pw else None
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()
return
# ── E2E encrypted DM routes ──
# POST /keys — register public key: body = name=...&key=<JWK JSON>
if method == "POST" and path == "/keys":
params = urllib.parse.parse_qs(body.decode())
name = params.get("name", [""])[0]
key = params.get("key", [""])[0]
if name and key:
user_keys[name] = key
save_keys()
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()
return
# GET /keys/<username> — get public key
if method == "GET" and path.startswith("/keys/"):
username = urllib.parse.unquote(path[6:])
key = user_keys.get(username)
if key:
resp = key.encode()
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: application/json\r\n")
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
writer.write(b"\r\n")
writer.write(resp)
else:
writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
await writer.drain()
writer.close()
return
# GET /keys — list all registered usernames
if method == "GET" and path == "/keys":
resp = json.dumps(list(user_keys.keys())).encode()
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: application/json\r\n")
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
writer.write(b"\r\n")
writer.write(resp)
await writer.drain()
writer.close()
return
# POST /dm — relay an encrypted DM: body = from=...&to=...&encrypted=...&nonce=...
if method == "POST" and path == "/dm":
params = urllib.parse.parse_qs(body.decode())
sender = params.get("from", [""])[0]
recipient = params.get("to", [""])[0]
encrypted = params.get("encrypted", [""])[0]
nonce = params.get("nonce", [""])[0]
if sender and recipient and encrypted:
dm_msg = {
"ts": time.time(), "user": sender, "dm": True,
"to": recipient, "encrypted": encrypted, "nonce": nonce
}
# Deliver to all SSE queues registered for this recipient
for q in dm_targets.get(recipient, []):
try:
q.put_nowait(dm_msg)
except Exception:
pass
# Also deliver to sender so they see their own DM
if sender != recipient:
for q in dm_targets.get(sender, []):
try:
q.put_nowait(dm_msg)
except Exception:
pass
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()
return
# GET /files/<id>/<filename> — download uploaded file # GET /files/<id>/<filename> — download uploaded file
if method == "GET" and path.startswith("/files/"): if method == "GET" and path.startswith("/files/"):
parts = path.split("/") parts = path.split("/")
@@ -640,16 +1209,17 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
await handle_http(reader, writer, first_line) await handle_http(reader, writer, first_line)
return return
# Raw TCP chat client — first line is the name # Raw TCP chat client — first line is the name (uses lobby group)
name = first_line name = first_line
clients[writer] = name grp = get_group(DEFAULT_GROUP)
grp.clients[writer] = name
for msg in history: for msg in grp.history:
writer.write((json.dumps(msg) + "\n").encode()) writer.write((json.dumps(msg) + "\n").encode())
await writer.drain() await writer.drain()
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"}) await grp.broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"})
print(f"+ {name} connected ({len(clients)} online)") print(f"+ {name} connected ({len(grp.clients)} online in {grp.name})")
try: try:
while True: while True:
@@ -659,7 +1229,6 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
line = data.decode().rstrip("\n") line = data.decode().rstrip("\n")
if not line: if not line:
continue continue
# Try JSON (new protocol: supports multiline + files)
try: try:
pkt = json.loads(line) pkt = json.loads(line)
if pkt.get("type") == "file": if pkt.get("type") == "file":
@@ -668,7 +1237,7 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
continue continue
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16] file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
store_file(file_id, file_data) store_file(file_id, file_data)
await broadcast({ await grp.broadcast({
"ts": time.time(), "user": name, "ts": time.time(), "user": name,
"text": f"[file: {pkt['filename']}]", "text": f"[file: {pkt['filename']}]",
"file_id": file_id, "filename": pkt["filename"], "file_id": file_id, "filename": pkt["filename"],
@@ -677,17 +1246,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
else: else:
text = pkt.get("text", "").strip() text = pkt.get("text", "").strip()
if text: if text:
await broadcast({"ts": time.time(), "user": name, "text": text}) await grp.broadcast({"ts": time.time(), "user": name, "text": text})
except (json.JSONDecodeError, KeyError): except (json.JSONDecodeError, KeyError):
# Legacy plain text (single line, backwards compat)
if line.strip(): if line.strip():
await broadcast({"ts": time.time(), "user": name, "text": line.strip()}) await grp.broadcast({"ts": time.time(), "user": name, "text": line.strip()})
except Exception: except Exception:
pass pass
finally: finally:
clients.pop(writer, None) grp.clients.pop(writer, None)
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"}) await grp.broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"})
print(f"- {name} disconnected ({len(clients)} online)") print(f"- {name} disconnected ({len(grp.clients)} online in {grp.name})")
writer.close() writer.close()