v0.0.21: TUI overhaul, WZP call infrastructure, security hardening, federation

TUI:
- Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod)
- Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge
- /help command, terminal bell on incoming DM, /devices + /kick commands
- 44 unit tests (types, input, draw with TestBackend)

Server — WZP Call Infrastructure (FC-2/3/5/6/7/10):
- Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees)
- WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline)
- Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out)
- Presence API (GET /presence/:fp, POST /presence/batch)
- Missed call flush on WS reconnect
- WZP relay config + CORS

Server — Security (FC-P1):
- Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers)
- Session auto-recovery (delete corrupted ratchet, show [session reset])
- WS connection cap (5/fingerprint) + global concurrency limit (200)
- Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all)

Server — Federation:
- Two-server federation via JSON config (--federation flag)
- Periodic presence sync (every 5s, full-state, self-healing)
- Message forwarding via HTTP POST with SHA-256(secret||body) auth
- Graceful degradation (peer down = queue locally)
- deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs

Client — Group Messaging:
- SenderKeyDistribution storage + GroupSenderKey decryption in TUI
- sender_keys sled tree in LocalDb

WASM:
- All 8 WireMessage variants handled (no more "unsupported")
- decrypt_group_message() + create_sender_key_from_distribution() exports
- CallSignal parsing with signal_type mapping

Docs:
- ARCHITECTURE.md rewritten with Mermaid diagrams
- README.md created
- TASK_PLAN.md with FC-P{phase}-T{task} naming
- PROGRESS.md updated to v0.0.21

WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-28 16:45:58 +04:00
parent 4a4fa9fab4
commit 3e0889e5dc
36 changed files with 5237 additions and 2232 deletions

190
warzone/Cargo.lock generated
View File

@@ -317,6 +317,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.9.1" version = "0.9.1"
@@ -529,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -541,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core", "rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -680,7 +686,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519", "ed25519",
"rand_core", "rand_core 0.6.4",
"serde", "serde",
"sha2", "sha2",
"subtle", "subtle",
@@ -706,7 +712,7 @@ dependencies = [
"generic-array", "generic-array",
"group", "group",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"sec1", "sec1",
"serdect", "serdect",
"subtle", "subtle",
@@ -750,7 +756,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@@ -897,6 +903,20 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.2"
@@ -905,7 +925,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi 6.0.0",
"wasip2", "wasip2",
"wasip3", "wasip3",
] ]
@@ -917,7 +937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [ dependencies = [
"ff", "ff",
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@@ -1078,6 +1098,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -1433,6 +1454,12 @@ dependencies = [
"hashbrown 0.15.5", "hashbrown 0.15.5",
] ]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -1624,7 +1651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@@ -1716,6 +1743,61 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -1725,6 +1807,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "6.0.0" version = "6.0.0"
@@ -1738,8 +1826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1749,7 +1847,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1761,6 +1869,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.28.1" version = "0.28.1"
@@ -1841,6 +1958,8 @@ dependencies = [
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@@ -1848,6 +1967,7 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls",
"tower 0.5.3", "tower 0.5.3",
"tower-http 0.6.8", "tower-http 0.6.8",
"tower-service", "tower-service",
@@ -1855,6 +1975,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -1881,6 +2002,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -1923,6 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@@ -1935,6 +2063,7 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -2171,7 +2300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -2497,6 +2626,10 @@ version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [ dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -2646,7 +2779,7 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"native-tls", "native-tls",
"rand", "rand 0.8.5",
"sha1", "sha1",
"thiserror 1.0.69", "thiserror 1.0.69",
"utf-8", "utf-8",
@@ -2802,7 +2935,7 @@ dependencies = [
"futures-util", "futures-util",
"hex", "hex",
"libc", "libc",
"rand", "rand 0.8.5",
"ratatui", "ratatui",
"reqwest", "reqwest",
"serde", "serde",
@@ -2843,7 +2976,7 @@ dependencies = [
"hex", "hex",
"hkdf", "hkdf",
"k256", "k256",
"rand", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -2867,9 +3000,11 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"futures-util", "futures-util",
"hex", "hex",
"rand", "rand 0.8.5",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sled", "sled",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@@ -2891,7 +3026,7 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
"hex", "hex",
"js-sys", "js-sys",
"rand", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"uuid", "uuid",
@@ -3028,6 +3163,25 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -3312,7 +3466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"rand_core", "rand_core 0.6.4",
"serde", "serde",
"zeroize", "zeroize",
] ]

View File

@@ -42,7 +42,7 @@ tokio = { version = "1", features = ["full"] }
# Server # Server
axum = { version = "0.7", features = ["ws"] } axum = { version = "0.7", features = ["ws"] }
tower = "0.4" tower = { version = "0.4", features = ["limit"] }
tower-http = { version = "0.5", features = ["cors", "trace"] } tower-http = { version = "0.5", features = ["cors", "trace"] }
# Client HTTP # Client HTTP

165
warzone/README.md Normal file
View File

@@ -0,0 +1,165 @@
# Warzone Messenger (featherChat)
End-to-end encrypted messenger with Signal protocol cryptography, voice/video call integration, and server federation.
## Features
- **E2E Encrypted DMs** — X3DH key exchange + Double Ratchet (forward secrecy)
- **Group Messaging** — Sender Key protocol (O(1) encryption, fan-out delivery)
- **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted
- **Voice/Video Calls** — WarzonePhone integration (QUIC SFU relay, ChaCha20-Poly1305 media)
- **Federation** — Two-server relay with HMAC-authenticated presence sync
- **TUI Client** — Full-featured terminal UI (ratatui, timestamps, scrolling, receipts)
- **Web Client** — Identical crypto via WASM (wasm-bindgen)
- **Ethereum Identity** — Same seed derives messaging keypair + Ethereum address (secp256k1)
- **BIP39 Seed** — 24-word mnemonic for identity backup/recovery
## Architecture
```
Clients (CLI / TUI / Web)
|
| E2E encrypted (ChaCha20-Poly1305)
|
warzone-server (axum + sled)
|
| Federation (HTTP + HMAC)
|
warzone-server (peer)
|
| Call signaling
|
WarzonePhone Relay (QUIC SFU)
```
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for full architecture with Mermaid diagrams.
## Quick Start
### Build
```bash
cd warzone
cargo build --release
```
### Generate Identity
```bash
./target/release/warzone-client init
# Outputs: 24-word BIP39 mnemonic + fingerprint
```
### Start Server
```bash
./target/release/warzone-server --bind 0.0.0.0:7700
```
### Start TUI
```bash
./target/release/warzone-client tui --server http://localhost:7700
```
### Federation (Two Servers)
Create `alpha.json`:
```json
{
"server_id": "alpha",
"shared_secret": "your-shared-secret",
"peer": { "id": "bravo", "url": "http://server-b:7700" },
"presence_interval_secs": 5
}
```
```bash
# Server A
warzone-server --bind 0.0.0.0:7700 --federation alpha.json
# Server B
warzone-server --bind 0.0.0.0:7700 --federation bravo.json
```
Messages automatically route across servers.
## TUI Commands
| Command | Description |
|---------|-------------|
| `/peer <fp>` or `/p @alias` | Set DM peer |
| `/g <name>` | Switch to group (auto-join) |
| `/call <fp>` | Initiate call |
| `/file <path>` | Send file (max 10MB) |
| `/contacts` | List contacts with message counts |
| `/history` | Show conversation history |
| `/devices` | List active device sessions |
| `/kick <id>` | Revoke a device session |
| `/help` | Full command list |
## Crates
| Crate | Purpose |
|-------|---------|
| `warzone-protocol` | Crypto & message types (X3DH, Double Ratchet, Sender Keys) |
| `warzone-server` | HTTP/WS server with sled DB, federation, call state |
| `warzone-client` | CLI + TUI client |
| `warzone-wasm` | WASM bridge for web client |
| `warzone-mule` | Physical message delivery (planned) |
## Cryptographic Stack
| Primitive | Purpose |
|-----------|---------|
| Ed25519 | Identity signing |
| X25519 | Diffie-Hellman key exchange |
| ChaCha20-Poly1305 | AEAD encryption |
| HKDF-SHA256 | Key derivation |
| Argon2id | Seed encryption at rest |
| secp256k1 | Ethereum-compatible identity |
## Security
- Auth enforcement on all write routes (bearer token middleware)
- Session auto-recovery on ratchet corruption
- Per-fingerprint WS connection cap (5 devices)
- Global request concurrency limit (200)
- Device management (list, kick, revoke-all panic button)
- Federation auth: SHA-256(secret || body) on every inter-server request
See [docs/SECURITY.md](docs/SECURITY.md) for the full threat model.
## Test Suite
72 tests across protocol + client crates (all passing):
- 28 protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity)
- 44 TUI tests (rendering, keyboard input, scrolling, state management)
```bash
cargo test --workspace
```
## WarzonePhone Integration
All 9 WZP-side integration tasks are complete:
- Shared identity (HKDF alignment, 15 cross-project tests)
- Relay auth (featherChat bearer token validation)
- Signaling bridge (CallSignal through E2E encrypted WS)
- Room access control (hashed room names, ACL)
- Mandatory crypto handshake on all paths
## Documentation
| Document | Content |
|----------|---------|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Full system architecture with Mermaid diagrams |
| [TASK_PLAN.md](docs/TASK_PLAN.md) | Phase-by-phase task plan (FC-P1 through P6) |
| [PROGRESS.md](docs/PROGRESS.md) | Version history and feature timeline |
| [PROTOCOL.md](docs/PROTOCOL.md) | Wire protocol specification |
| [SECURITY.md](docs/SECURITY.md) | Threat model and security analysis |
| [FUTURE_TASKS.md](docs/FUTURE_TASKS.md) | Backlog with questions-before-starting |
## License
MIT

View File

@@ -10,6 +10,7 @@ pub struct LocalDb {
pre_keys: sled::Tree, pre_keys: sled::Tree,
contacts: sled::Tree, contacts: sled::Tree,
history: sled::Tree, history: sled::Tree,
sender_keys: sled::Tree,
_db: sled::Db, _db: sled::Db,
} }
@@ -39,11 +40,13 @@ impl LocalDb {
let pre_keys = db.open_tree("pre_keys")?; let pre_keys = db.open_tree("pre_keys")?;
let contacts = db.open_tree("contacts")?; let contacts = db.open_tree("contacts")?;
let history = db.open_tree("history")?; let history = db.open_tree("history")?;
let sender_keys = db.open_tree("sender_keys")?;
Ok(LocalDb { Ok(LocalDb {
sessions, sessions,
pre_keys, pre_keys,
contacts, contacts,
history, history,
sender_keys,
_db: db, _db: db,
}) })
} }
@@ -57,6 +60,14 @@ impl LocalDb {
Ok(()) Ok(())
} }
/// Delete a ratchet session for a peer (used for session recovery).
pub fn delete_session(&self, peer: &Fingerprint) -> Result<()> {
let key = peer.to_hex();
self.sessions.remove(key.as_bytes())?;
self.sessions.flush()?;
Ok(())
}
/// Load a ratchet session for a peer. /// Load a ratchet session for a peer.
pub fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>> { pub fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>> {
let key = peer.to_hex(); let key = peer.to_hex();
@@ -115,6 +126,39 @@ impl LocalDb {
} }
} }
// ── Sender Keys ──
/// Save a sender key for a (sender, group) pair.
pub fn save_sender_key(
&self,
sender_fp: &str,
group_name: &str,
key: &warzone_protocol::sender_keys::SenderKey,
) -> Result<()> {
let db_key = format!("sk:{}:{}", sender_fp, group_name);
let data = bincode::serialize(key).context("failed to serialize sender key")?;
self.sender_keys.insert(db_key.as_bytes(), data)?;
self.sender_keys.flush()?;
Ok(())
}
/// Load a sender key for a (sender, group) pair.
pub fn load_sender_key(
&self,
sender_fp: &str,
group_name: &str,
) -> Result<Option<warzone_protocol::sender_keys::SenderKey>> {
let db_key = format!("sk:{}:{}", sender_fp, group_name);
match self.sender_keys.get(db_key.as_bytes())? {
Some(data) => {
let key = bincode::deserialize(&data)
.context("failed to deserialize sender key")?;
Ok(Some(key))
}
None => Ok(None),
}
}
// ── Contacts ── // ── Contacts ──
/// Add or update a contact. Called on send/receive. /// Add or update a contact. Called on send/receive.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,798 @@
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::WireMessage;
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::types::Fingerprint;
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{App, ChatLine, ReceiptStatus, normfp};
impl App {
pub async fn handle_send(
&mut self,
identity: &IdentityKeyPair,
db: &LocalDb,
client: &ServerClient,
) {
let text = self.input.trim().to_string();
self.input.clear();
self.cursor_pos = 0;
if text.is_empty() {
return;
}
// Commands
if text == "/quit" || text == "/q" {
self.should_quit = true;
return;
}
if text == "/info" {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Your fingerprint: {}", self.our_fp),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
if text == "/help" || text == "/?" {
let help_lines = [
"Commands:",
" /help, /? Show this help",
" /info Show your fingerprint",
" /eth Show Ethereum address",
" /peer <fp>, /p Set DM peer by fingerprint",
" /peer @alias Set DM peer by alias",
" /reply, /r Reply to last DM sender",
" /dm Switch to DM mode (clear peer)",
" /contacts, /c List contacts with message counts",
" /history, /h [fp] Show conversation history",
" /alias <name> Register an alias for yourself",
" /aliases List all registered aliases",
" /unalias Remove your alias",
" /devices List your active device sessions",
" /kick <device_id> Kick a specific device session",
" /g <name> Switch to group (auto-join)",
" /gcreate <name> Create a new group",
" /gjoin <name> Join a group",
" /glist List all groups",
" /gleave Leave current group",
" /gkick <fp> Kick member from group",
" /gmembers List group members",
" /file <path> Send a file (max 10MB)",
" /quit, /q Exit",
"",
"Navigation:",
" PageUp/PageDown Scroll messages",
" Up/Down Scroll by 1 (when input empty)",
" Ctrl+C, Esc Quit",
];
for line in &help_lines {
self.add_message(ChatLine {
sender: "system".into(),
text: line.to_string(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
return;
}
if text.starts_with("/alias ") {
let name = text[7..].trim();
self.register_alias(name, client).await;
return;
}
if text == "/aliases" {
self.list_aliases(client).await;
return;
}
if text == "/unalias" {
let url = format!("{}/v1/alias/unregister", client.base_url);
match client.client.post(&url)
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)}))
.send().await
{
Ok(resp) => if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
},
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
return;
}
if text == "/contacts" || text == "/c" {
match db.list_contacts() {
Ok(contacts) => {
if contacts.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
for c in &contacts {
let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
let alias = c.get("alias").and_then(|v| v.as_str());
let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
let label = match alias {
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count),
None => format!(" {}{} msgs", &fp[..fp.len().min(16)], count),
};
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
return;
}
if text.starts_with("/history") || text.starts_with("/h ") {
let peer = if text.starts_with("/h ") { text[3..].trim() } else if text.starts_with("/history ") { text[9..].trim() } else { "" };
let fp = if let Some(ref p) = self.peer_fp { if !p.starts_with('#') { p.as_str() } else { peer } } else { peer };
if fp.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h <fingerprint> (or set peer first)".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
match db.get_history(fp, 50) {
Ok(msgs) => {
if msgs.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
for m in &msgs {
let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or("?");
let txt = m.get("text").and_then(|v| v.as_str()).unwrap_or("");
let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false);
self.add_message(ChatLine {
sender: sender[..sender.len().min(12)].to_string(),
text: txt.to_string(),
is_system: false,
is_self,
message_id: None, timestamp: Local::now(),
});
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
return;
}
if text == "/eth" {
// Show ethereum address from seed
if let Ok(seed) = crate::keystore::load_seed_raw() {
let eth = warzone_protocol::ethereum::derive_eth_identity(&seed);
self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
return;
}
if text == "/devices" {
let url = format!("{}/v1/devices", client.base_url);
// Try to get bearer token from a recent auth (for now, make unauthenticated GET)
match client.client.get(&url).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) {
if devices.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
for d in devices {
let id = d.get("device_id").and_then(|v| v.as_str()).unwrap_or("?");
let connected = d.get("connected_at").and_then(|v| v.as_i64()).unwrap_or(0);
let when = chrono::DateTime::from_timestamp(connected, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "?".to_string());
self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
} else if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
return;
}
if text.starts_with("/kick ") {
let device_id = text[6..].trim();
if device_id.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick <device_id>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
return;
}
let url = format!("{}/v1/devices/{}/kick", client.base_url, device_id);
match client.client.post(&url).json(&serde_json::json!({})).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(kicked) = data.get("kicked").and_then(|v| v.as_str()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
return;
}
if text == "/r" || text == "/reply" {
let last = self.last_dm_peer.lock().unwrap().clone();
if let Some(ref peer) = last {
self.peer_fp = Some(peer.clone());
self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
return;
}
if text.starts_with("/peer ") || text.starts_with("/p ") {
let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() };
let raw = text[6..].trim().to_string();
let fp = if raw.starts_with('@') {
match self.resolve_alias(&raw[1..], client).await {
Some(resolved) => resolved,
None => return,
}
} else {
raw
};
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Peer set to {}", fp),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
self.peer_fp = Some(fp);
return;
}
if text.starts_with("/gcreate ") {
let name = text[9..].trim();
self.group_create(name, client).await;
return;
}
if text.starts_with("/gjoin ") {
let name = text[7..].trim();
self.group_join(name, client).await;
return;
}
if text.starts_with("/g ") {
let name = text[3..].trim().to_string();
// Auto-join
self.group_join(&name, client).await;
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Switched to group #{}", name),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
self.peer_fp = Some(format!("#{}", name));
return;
}
if text == "/dm" {
self.add_message(ChatLine {
sender: "system".into(),
text: "Switched to DM mode. Use /peer <fp>".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
self.peer_fp = None;
return;
}
if text == "/glist" {
self.group_list(client).await;
return;
}
if text == "/gleave" {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
self.group_leave(&name, client).await;
self.peer_fp = None;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g <name> first".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
return;
}
if text.starts_with("/gkick ") {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
let target = text[7..].trim().to_string();
self.group_kick(&name, &target, client).await;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
return;
}
if text == "/gmembers" {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
self.group_members(&name, client).await;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
return;
}
if text.starts_with("/file ") {
let path_str = text[6..].trim();
self.handle_file_send(path_str, identity, db, client).await;
return;
}
// Send message (group or DM)
let peer = match &self.peer_fp {
Some(p) if p.starts_with('#') => {
// Group mode
let group_name = p[1..].to_string();
self.group_send(&group_name, &text, identity, db, client).await;
return;
}
Some(p) => p.clone(),
None => {
self.add_message(ChatLine {
sender: "system".into(),
text: "No peer set. Use /peer <fingerprint>".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
};
let peer_fp = match Fingerprint::from_hex(&peer) {
Ok(fp) => fp,
Err(_) => {
self.add_message(ChatLine {
sender: "system".into(),
text: "Invalid peer fingerprint".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
};
let msg_id = uuid::Uuid::new_v4().to_string();
let our_pub = identity.public_identity();
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
let wire_msg = if let Some(ref mut state) = ratchet {
match state.encrypt(text.as_bytes()) {
Ok(encrypted) => {
let _ = db.save_session(&peer_fp, state);
WireMessage::Message {
id: msg_id.clone(),
sender_fingerprint: our_pub.fingerprint.to_string(),
ratchet_message: encrypted,
}
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Encrypt failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
}
} else {
// X3DH
let bundle = match client.fetch_bundle(&peer).await {
Ok(b) => b,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to fetch bundle: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
};
let x3dh_result = match x3dh::initiate(identity, &bundle) {
Ok(r) => r,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("X3DH failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
};
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
match state.encrypt(text.as_bytes()) {
Ok(encrypted) => {
let _ = db.save_session(&peer_fp, &state);
WireMessage::KeyExchange {
id: msg_id.clone(),
sender_fingerprint: our_pub.fingerprint.to_string(),
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
ratchet_message: encrypted,
}
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Encrypt failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
}
};
let encoded = match bincode::serialize(&wire_msg) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
};
match client.send_message(&peer, Some(&self.our_fp), &encoded).await {
Ok(_) => {
// Track receipt status
self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent);
// Store in contacts + history
let _ = db.touch_contact(&peer, None);
let _ = db.store_message(&peer, &self.our_fp, &text, true);
self.add_message(ChatLine {
sender: self.our_fp[..12].to_string(),
text: text.clone(),
is_system: false,
is_self: true,
message_id: Some(msg_id), timestamp: Local::now(),
});
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Send failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
}
}
pub(crate) async fn group_create(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/create", client.base_url);
match client.client.post(&url)
.json(&serde_json::json!({"name": name, "creator": normfp(&self.our_fp)}))
.send().await
{
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
pub(crate) async fn group_join(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/join", client.base_url, name);
match client.client.post(&url)
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)}))
.send().await
{
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
async fn group_list(&self, client: &ServerClient) {
let url = format!("{}/v1/groups", client.base_url);
match client.client.get(&url).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) {
if groups.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
for g in groups {
let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let members = g.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
async fn group_leave(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/leave", client.base_url, name);
match client.client.post(&url)
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)}))
.send().await
{
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
async fn group_kick(&self, name: &str, target: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/kick", client.base_url, name);
match client.client.post(&url)
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp), "target": target}))
.send().await
{
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
let kicked = data.get("kicked").and_then(|v| v.as_str()).unwrap_or("?");
self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
async fn group_members(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/members", client.base_url, name);
match client.client.get(&url).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(members) = data.get("members").and_then(|v| v.as_array()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
for m in members {
let fp = m.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
let alias = m.get("alias").and_then(|v| v.as_str());
let creator = m.get("is_creator").and_then(|v| v.as_bool()).unwrap_or(false);
let label = match alias {
Some(a) => format!(" @{} ({}{})", a, &fp[..fp.len().min(12)], if creator { "" } else { "" }),
None => format!(" {}...{}", &fp[..fp.len().min(12)], if creator { "" } else { "" }),
};
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
pub(crate) async fn group_send(
&self,
group_name: &str,
text: &str,
identity: &IdentityKeyPair,
db: &LocalDb,
client: &ServerClient,
) {
// Get members
let url = format!("{}/v1/groups/{}", client.base_url, group_name);
let group_data = match client.client.get(&url).send().await {
Ok(resp) => match resp.json::<serde_json::Value>().await {
Ok(d) => d,
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; }
},
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; }
};
let my_fp = normfp(&self.our_fp);
let members: Vec<String> = group_data.get("members")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let our_pub = identity.public_identity();
let mut wire_messages: Vec<serde_json::Value> = Vec::new();
for member in &members {
if *member == my_fp { continue; }
let member_fp = match Fingerprint::from_hex(member) {
Ok(fp) => fp,
Err(_) => continue,
};
let mut ratchet = db.load_session(&member_fp).ok().flatten();
let wire_msg = if let Some(ref mut state) = ratchet {
match state.encrypt(text.as_bytes()) {
Ok(encrypted) => {
let _ = db.save_session(&member_fp, state);
WireMessage::Message {
id: uuid::Uuid::new_v4().to_string(),
sender_fingerprint: our_pub.fingerprint.to_string(),
ratchet_message: encrypted,
}
}
Err(_) => continue,
}
} else {
// Need X3DH — fetch bundle
let bundle = match client.fetch_bundle(member).await {
Ok(b) => b,
Err(_) => continue,
};
let x3dh_result = match x3dh::initiate(identity, &bundle) {
Ok(r) => r,
Err(_) => continue,
};
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
match state.encrypt(text.as_bytes()) {
Ok(encrypted) => {
let _ = db.save_session(&member_fp, &state);
WireMessage::KeyExchange {
id: uuid::Uuid::new_v4().to_string(),
sender_fingerprint: our_pub.fingerprint.to_string(),
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
ratchet_message: encrypted,
}
}
Err(_) => continue,
}
};
let encoded = match bincode::serialize(&wire_msg) {
Ok(e) => e,
Err(_) => continue,
};
wire_messages.push(serde_json::json!({
"to": member,
"message": encoded,
}));
}
if wire_messages.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
return;
}
let send_url = format!("{}/v1/groups/{}/send", client.base_url, group_name);
match client.client.post(&send_url)
.json(&serde_json::json!({
"from": my_fp,
"messages": wire_messages,
}))
.send().await
{
Ok(_) => {
self.add_message(ChatLine {
sender: format!("{} [#{}]", &self.our_fp[..12], group_name),
text: text.to_string(),
is_system: false,
is_self: true,
message_id: None, timestamp: Local::now(),
});
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
async fn register_alias(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/alias/register", client.base_url);
match client.client.post(&url)
.json(&serde_json::json!({"alias": name, "fingerprint": normfp(&self.our_fp)}))
.send().await
{
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
let alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name);
self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
pub(crate) async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option<String> {
let url = format!("{}/v1/alias/resolve/{}", client.base_url, name);
match client.client.get(&url).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
return Some(fp.to_string());
}
if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
None
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
None
}
}
}
async fn list_aliases(&self, client: &ServerClient) {
let url = format!("{}/v1/alias/list", client.base_url);
match client.client.get(&url).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) {
if aliases.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
for a in aliases {
let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?");
let fp = a.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
}

View File

@@ -0,0 +1,377 @@
use std::sync::atomic::Ordering;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use super::types::{App, ReceiptStatus};
impl App {
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
match message_id {
Some(id) => {
let receipts = self.receipts.lock().unwrap();
match receipts.get(id) {
Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read)
Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered)
Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent)
}
}
None => "",
}
}
fn receipt_color(&self, message_id: &Option<String>) -> Color {
match message_id {
Some(id) => {
let receipts = self.receipts.lock().unwrap();
match receipts.get(id) {
Some(ReceiptStatus::Read) => Color::Blue,
Some(ReceiptStatus::Delivered) => Color::White,
Some(ReceiptStatus::Sent) | None => Color::DarkGray,
}
}
None => Color::DarkGray,
}
}
pub fn draw(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(5), // messages
Constraint::Length(3), // input
])
.split(frame.area());
// Header
let peer_str = self
.peer_fp
.as_deref()
.unwrap_or("no peer");
let is_connected = self.connected.load(Ordering::Relaxed);
let (conn_indicator, conn_color) = if is_connected {
(" \u{25CF}", Color::Green) // ●
} else {
(" \u{25CF}", Color::Red) // ●
};
let header = Paragraph::new(Line::from(vec![
Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::styled(&self.our_fp, Style::default().fg(Color::Green)),
Span::raw(" \u{2192} "),
Span::styled(peer_str, Style::default().fg(Color::Yellow)),
Span::styled(
format!(" [{}]", self.server_url),
Style::default().fg(Color::DarkGray),
),
Span::styled(conn_indicator, Style::default().fg(conn_color)),
]));
frame.render_widget(header, chunks[0]);
// Messages
let msgs = self.messages.lock().unwrap();
let items: Vec<ListItem> = msgs
.iter()
.map(|m| {
let style = if m.is_system {
Style::default().fg(Color::Cyan)
} else if m.is_self {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Yellow)
};
let timestamp = format!("[{}] ", m.timestamp.format("%H:%M"));
let prefix = if m.is_system {
"*** ".to_string()
} else {
format!("{}: ", &m.sender[..m.sender.len().min(12)])
};
let receipt_str = if m.is_self && m.message_id.is_some() {
self.receipt_indicator(&m.message_id)
} else {
""
};
let receipt_color = self.receipt_color(&m.message_id);
ListItem::new(Line::from(vec![
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
Span::raw(&m.text),
Span::styled(receipt_str, Style::default().fg(receipt_color)),
]))
})
.collect();
// Scroll support: compute the visible window of items
let visible_height = chunks[1].height.saturating_sub(1) as usize; // minus top border
let total = items.len();
let end = total.saturating_sub(self.scroll_offset);
let start = end.saturating_sub(visible_height);
let visible_items = if total == 0 {
vec![]
} else {
items[start..end].to_vec()
};
let messages_widget = List::new(visible_items)
.block(Block::default().borders(Borders::TOP));
frame.render_widget(messages_widget, chunks[1]);
// Input
let input_title = if self.scroll_offset > 0 {
format!(" [{} new \u{2193}] ", self.scroll_offset)
} else {
" message ".to_string()
};
let input_widget = Paragraph::new(self.input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(input_title),
)
.wrap(Wrap { trim: false });
frame.render_widget(input_widget, chunks[2]);
// Cursor
let x = (self.cursor_pos as u16 + 1).min(chunks[2].width - 2);
frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1));
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use chrono::Local;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use super::super::types::{App, ChatLine};
/// Helper: collect the entire terminal buffer into a single String.
fn full_buffer_text(terminal: &Terminal<TestBackend>) -> String {
let buf = terminal.backend().buffer();
(0..buf.area().height)
.flat_map(|y| {
(0..buf.area().width).map(move |x| {
buf.cell((x, y))
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.unwrap_or(' ')
})
})
.collect()
}
/// Helper: check whether the buffer contains `needle`.
fn buffer_contains(terminal: &Terminal<TestBackend>, needle: &str) -> bool {
full_buffer_text(terminal).contains(needle)
}
/// Helper: collect a single row into a String.
fn row_text(terminal: &Terminal<TestBackend>, row: u16) -> String {
let buf = terminal.backend().buffer();
(0..buf.area().width)
.map(|x| {
buf.cell((x, row))
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.unwrap_or(' ')
})
.collect()
}
fn make_app() -> App {
App::new("aabbcc".into(), Some("ddeeff".into()), "localhost:7700".into())
}
fn make_terminal() -> Terminal<TestBackend> {
let backend = TestBackend::new(80, 24);
Terminal::new(backend).expect("terminal creation should succeed")
}
// ----------------------------------------------------------------
// 1. draw_does_not_panic
// ----------------------------------------------------------------
#[test]
fn draw_does_not_panic() {
let app = make_app();
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).expect("draw should not fail");
}
// ----------------------------------------------------------------
// 2. header_contains_fingerprint
// ----------------------------------------------------------------
#[test]
fn header_contains_fingerprint() {
let app = make_app();
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
let header = row_text(&terminal, 0);
assert!(
header.contains("aabbcc"),
"header should contain our fingerprint 'aabbcc', got: {header}"
);
}
// ----------------------------------------------------------------
// 3. connection_indicator_red_when_disconnected
// ----------------------------------------------------------------
#[test]
fn connection_indicator_red_when_disconnected() {
let app = make_app();
// connected defaults to false
assert!(!app.connected.load(Ordering::Relaxed));
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
let header = row_text(&terminal, 0);
assert!(
header.contains('\u{25CF}'),
"header should contain the dot character when disconnected, got: {header}"
);
}
// ----------------------------------------------------------------
// 4. connection_indicator_green_when_connected
// ----------------------------------------------------------------
#[test]
fn connection_indicator_green_when_connected() {
let app = make_app();
app.connected.store(true, Ordering::Relaxed);
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
let header = row_text(&terminal, 0);
assert!(
header.contains('\u{25CF}'),
"header should contain the dot character when connected, got: {header}"
);
}
// ----------------------------------------------------------------
// 5. timestamp_format_in_messages
// ----------------------------------------------------------------
#[test]
fn timestamp_format_in_messages() {
let app = make_app();
app.add_message(ChatLine {
sender: "alice".into(),
text: "hello world".into(),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
let text = full_buffer_text(&terminal);
// Timestamps are rendered as [HH:MM] — look for the bracket pattern.
assert!(
text.contains('[') && text.contains(']'),
"buffer should contain timestamp brackets, got: {text}"
);
}
// ----------------------------------------------------------------
// 6. scroll_offset_zero_shows_latest_messages
// ----------------------------------------------------------------
#[test]
fn scroll_offset_zero_shows_latest_messages() {
let app = make_app();
for i in 0..30 {
app.add_message(ChatLine {
sender: "bot".into(),
text: format!("msg-{i:03}"),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
// scroll_offset defaults to 0 — pinned to bottom.
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
buffer_contains(&terminal, "msg-029"),
"the last message should be visible when scroll_offset is 0"
);
}
// ----------------------------------------------------------------
// 7. scroll_offset_hides_latest_messages
// ----------------------------------------------------------------
#[test]
fn scroll_offset_hides_latest_messages() {
let mut app = make_app();
for i in 0..30 {
app.add_message(ChatLine {
sender: "bot".into(),
text: format!("msg-{i:03}"),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
app.scroll_offset = 10;
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
!buffer_contains(&terminal, "msg-029"),
"the last message should NOT be visible when scroll_offset=10"
);
}
// ----------------------------------------------------------------
// 8. unread_badge_shows_when_scrolled
// ----------------------------------------------------------------
#[test]
fn unread_badge_shows_when_scrolled() {
let mut app = make_app();
app.scroll_offset = 5;
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
buffer_contains(&terminal, "new"),
"buffer should contain 'new' from the unread badge when scrolled"
);
}
// ----------------------------------------------------------------
// 9. no_unread_badge_at_bottom
// ----------------------------------------------------------------
#[test]
fn no_unread_badge_at_bottom() {
let app = make_app();
// scroll_offset is 0 by default
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
buffer_contains(&terminal, "message"),
"buffer should contain the default title 'message' when not scrolled"
);
assert!(
!full_buffer_text(&terminal).contains("new \u{2193}"),
"buffer should NOT contain 'new ↓' when scroll_offset is 0"
);
}
}

View File

@@ -0,0 +1,292 @@
use std::path::PathBuf;
use sha2::{Sha256, Digest};
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::WireMessage;
use warzone_protocol::types::Fingerprint;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{App, ChatLine, normfp, MAX_FILE_SIZE, CHUNK_SIZE};
impl App {
pub async fn handle_file_send(
&mut self,
path_str: &str,
identity: &IdentityKeyPair,
db: &LocalDb,
client: &ServerClient,
) {
let path = PathBuf::from(path_str);
if !path.exists() {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File not found: {}", path_str),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Cannot read file: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
let file_size = metadata.len();
if file_size > MAX_FILE_SIZE {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
let file_data = match std::fs::read(&path) {
Ok(d) => d,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to read file: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unnamed".to_string());
// SHA-256 of the complete file
let mut hasher = Sha256::new();
hasher.update(&file_data);
let sha256 = format!("{:x}", hasher.finalize());
let file_id = uuid::Uuid::new_v4().to_string();
let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32;
// Resolve peer (or group members)
let peer = match &self.peer_fp {
Some(p) => p.clone(),
None => {
self.add_message(ChatLine {
sender: "system".into(),
text: "Set a peer or group first".into(),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
// Group file transfer: send to each member
if peer.starts_with('#') {
let group_name = &peer[1..];
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sending '{}' to group #{}...", filename, group_name),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
// Get members
let url = format!("{}/v1/groups/{}", client.base_url, group_name);
let group_data = match client.client.get(&url).send().await {
Ok(resp) => match resp.json::<serde_json::Value>().await {
Ok(d) => d,
Err(_) => return,
},
Err(_) => return,
};
let my_fp = normfp(&self.our_fp);
let members: Vec<String> = group_data.get("members")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
for member in &members {
if *member == my_fp { continue; }
// Send file header + chunks to each member via HTTP
let header = WireMessage::FileHeader {
id: file_id.clone(),
sender_fingerprint: self.our_fp.clone(),
filename: filename.clone(),
file_size,
total_chunks,
sha256: sha256.clone(),
};
if let Ok(encoded) = bincode::serialize(&header) {
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
}
for i in 0..total_chunks {
let start = i as usize * CHUNK_SIZE;
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
let chunk_msg = WireMessage::FileChunk {
id: file_id.clone(),
sender_fingerprint: self.our_fp.clone(),
filename: filename.clone(),
chunk_index: i,
total_chunks,
data: file_data[start..end].to_vec(),
};
if let Ok(encoded) = bincode::serialize(&chunk_msg) {
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
}
}
}
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File '{}' sent to group #{}", filename, group_name),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
};
let peer_fp = match Fingerprint::from_hex(&peer) {
Ok(fp) => fp,
Err(_) => {
self.add_message(ChatLine {
sender: "system".into(),
text: "Invalid peer fingerprint".into(),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
let our_pub = identity.public_identity();
let our_fp_str = our_pub.fingerprint.to_string();
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
// Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data)
let header = WireMessage::FileHeader {
id: file_id.clone(),
sender_fingerprint: our_fp_str.clone(),
filename: filename.clone(),
file_size,
total_chunks,
sha256: sha256.clone(),
};
let encoded_header = match bincode::serialize(&header) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize header failed: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to send file header: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
// Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk
for i in 0..total_chunks {
let start = i as usize * CHUNK_SIZE;
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
let chunk_data = &file_data[start..end];
// Encrypt chunk data with ratchet
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
let encrypted_data = if let Some(ref mut state) = ratchet {
match state.encrypt(chunk_data) {
Ok(encrypted) => {
let _ = db.save_session(&peer_fp, state);
match bincode::serialize(&encrypted) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize chunk failed: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
}
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Encrypt chunk {} failed: {}", i, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
}
} else {
self.add_message(ChatLine {
sender: "system".into(),
text: "No ratchet session. Send a text message first to establish one.".into(),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
};
let chunk_msg = WireMessage::FileChunk {
id: file_id.clone(),
sender_fingerprint: our_fp_str.clone(),
filename: filename.clone(),
chunk_index: i,
total_chunks,
data: encrypted_data,
};
let encoded = match bincode::serialize(&chunk_msg) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize chunk {} failed: {}", i, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
}
self.add_message(ChatLine {
sender: self.our_fp[..12.min(self.our_fp.len())].to_string(),
text: format!("Sent file: {} ({} bytes)", filename, file_size),
is_system: false, is_self: true, message_id: None, timestamp: Local::now(),
});
}
}

View File

@@ -0,0 +1,377 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::types::App;
impl App {
/// Handle a single key event. Returns true if the event was consumed.
pub fn handle_key_event(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
// Alt+Backspace: delete word before cursor
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
if self.cursor_pos > 0 {
let before = &self.input[..self.cursor_pos];
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
self.input.drain(new_pos..self.cursor_pos);
self.cursor_pos = new_pos;
}
}
// Backspace: delete char before cursor
KeyCode::Backspace => {
if self.cursor_pos > 0 {
self.input.remove(self.cursor_pos - 1);
self.cursor_pos -= 1;
}
}
// Delete: delete char at cursor
KeyCode::Delete => {
if self.cursor_pos < self.input.len() {
self.input.remove(self.cursor_pos);
}
}
// Left arrow
KeyCode::Left => {
if key.modifiers.contains(KeyModifiers::ALT) {
// Alt+Left: word left
let before = &self.input[..self.cursor_pos];
self.cursor_pos = before.rfind(' ').unwrap_or(0);
} else if self.cursor_pos > 0 {
self.cursor_pos -= 1;
}
}
// Right arrow
KeyCode::Right => {
if key.modifiers.contains(KeyModifiers::ALT) {
// Alt+Right: word right
let after = &self.input[self.cursor_pos..];
self.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len());
} else if self.cursor_pos < self.input.len() {
self.cursor_pos += 1;
}
}
// Home / Ctrl+A
KeyCode::Home => { self.cursor_pos = 0; }
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.cursor_pos = 0;
}
// End: cursor to end of input when typing, snap to bottom when input is empty.
// Ctrl+End always snaps to bottom.
KeyCode::End => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
// Ctrl+End: always snap scroll to bottom
self.scroll_offset = 0;
} else if self.input.is_empty() {
// Plain End with empty input: snap scroll to bottom
self.scroll_offset = 0;
} else {
// Plain End with text: move cursor to end of input
self.cursor_pos = self.input.len();
}
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.cursor_pos = self.input.len();
}
// Ctrl+U: clear line
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.input.clear();
self.cursor_pos = 0;
}
// Ctrl+K: kill to end of line
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.input.truncate(self.cursor_pos);
}
// Ctrl+W: delete word back
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let before = &self.input[..self.cursor_pos];
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
self.input.drain(new_pos..self.cursor_pos);
self.cursor_pos = new_pos;
}
// PageUp: scroll up by 10 messages
KeyCode::PageUp => {
let max = self.messages.lock().unwrap().len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + 10).min(max);
}
// PageDown: scroll down by 10 messages
KeyCode::PageDown => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
}
// Up arrow: scroll up by 1 (only when input is empty)
KeyCode::Up if self.input.is_empty() => {
let max = self.messages.lock().unwrap().len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + 1).min(max);
}
// Down arrow: scroll down by 1 (only when input is empty)
KeyCode::Down if self.input.is_empty() => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
// Regular char: insert at cursor
KeyCode::Char(c) => {
self.input.insert(self.cursor_pos, c);
self.cursor_pos += 1;
}
KeyCode::Esc => {
self.should_quit = true;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::types::App;
/// Helper: create a key event with no modifiers.
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
/// Helper: create a key event with modifiers.
fn key_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
/// Helper: create a fresh App for testing.
fn app() -> App {
App::new("aabbcc".into(), None, "http://localhost:7700".into())
}
/// Helper: type a string into the app one character at a time.
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.handle_key_event(key(KeyCode::Char(c)));
}
}
// ── Text editing tests ──────────────────────────────────────────
#[test]
fn char_insert() {
let mut app = app();
type_str(&mut app, "abc");
assert_eq!(app.input, "abc");
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn backspace_deletes_char() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Backspace));
assert_eq!(app.input, "ab");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn backspace_at_start_does_nothing() {
let mut app = app();
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
app.handle_key_event(key(KeyCode::Backspace));
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn delete_at_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Left));
app.handle_key_event(key(KeyCode::Delete));
assert_eq!(app.input, "ab");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn ctrl_u_clears_line() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key_mod(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn ctrl_k_kills_to_end() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::Right));
app.handle_key_event(key(KeyCode::Right));
app.handle_key_event(key_mod(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert_eq!(app.input, "he");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn ctrl_w_deletes_word() {
let mut app = app();
type_str(&mut app, "hello world");
app.handle_key_event(key_mod(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_pos, 6);
}
#[test]
fn alt_backspace_deletes_word() {
let mut app = app();
type_str(&mut app, "hello world");
app.handle_key_event(key_mod(KeyCode::Backspace, KeyModifiers::ALT));
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_pos, 6);
}
// ── Cursor movement tests ───────────────────────────────────────
#[test]
fn left_arrow_moves_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Left));
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn right_arrow_moves_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::Right));
assert_eq!(app.cursor_pos, 1);
}
#[test]
fn home_moves_to_start() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn end_moves_to_end() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::End));
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn ctrl_a_moves_to_start() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key_mod(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn ctrl_e_moves_to_end() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key_mod(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn left_at_start_does_nothing() {
let mut app = app();
assert_eq!(app.cursor_pos, 0);
app.handle_key_event(key(KeyCode::Left));
assert_eq!(app.cursor_pos, 0);
}
// ── Quit tests ──────────────────────────────────────────────────
#[test]
fn ctrl_c_quits() {
let mut app = app();
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(app.should_quit);
}
#[test]
fn esc_quits() {
let mut app = app();
app.handle_key_event(key(KeyCode::Esc));
assert!(app.should_quit);
}
// ── Scroll tests ────────────────────────────────────────────────
#[test]
fn page_up_increases_scroll_offset() {
let mut app = app();
// App::new creates 3 system messages, so max = 3 - 1 = 2
let msg_count = app.messages.lock().unwrap().len();
app.handle_key_event(key(KeyCode::PageUp));
// scroll_offset = min(10, msg_count - 1)
let expected = 10usize.min(msg_count.saturating_sub(1));
assert_eq!(app.scroll_offset, expected);
}
#[test]
fn page_down_decreases_scroll_offset() {
let mut app = app();
app.scroll_offset = 15;
app.handle_key_event(key(KeyCode::PageDown));
assert_eq!(app.scroll_offset, 5);
}
#[test]
fn page_down_clamps_to_zero() {
let mut app = app();
app.scroll_offset = 3;
app.handle_key_event(key(KeyCode::PageDown));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn up_arrow_scrolls_when_input_empty() {
let mut app = app();
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 1);
}
#[test]
fn up_arrow_ignored_when_input_not_empty() {
let mut app = app();
type_str(&mut app, "hi");
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn down_arrow_scrolls_when_input_empty() {
let mut app = app();
app.scroll_offset = 5;
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 4);
}
#[test]
fn down_arrow_at_zero_stays_zero() {
let mut app = app();
assert!(app.input.is_empty());
assert_eq!(app.scroll_offset, 0);
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn end_snaps_to_bottom_when_input_empty() {
let mut app = app();
app.scroll_offset = 10;
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::End));
assert_eq!(app.scroll_offset, 0);
}
}

View File

@@ -1,3 +1,72 @@
pub mod app; mod types;
mod draw;
mod commands;
mod file_transfer;
mod input;
mod network;
pub use app::run_tui; pub use types::App;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode};
use warzone_protocol::identity::{IdentityKeyPair, Seed};
use crate::net::ServerClient;
use crate::storage::LocalDb;
/// Run the TUI event loop.
pub async fn run_tui(
our_fp: String,
peer_fp: Option<String>,
server_url: String,
identity: IdentityKeyPair,
poll_seed: Seed,
db: LocalDb,
) -> Result<()> {
let mut terminal = ratatui::init();
let client = ServerClient::new(&server_url);
let db = Arc::new(db);
let mut app = App::new(our_fp.clone(), peer_fp, server_url);
// Derive a second identity for the poll loop (can't clone IdentityKeyPair)
let poll_identity = poll_seed.derive_identity();
let poll_messages = app.messages.clone();
let poll_receipts = app.receipts.clone();
let poll_pending_files = app.pending_files.clone();
let poll_last_dm = app.last_dm_peer.clone();
let poll_connected = app.connected.clone();
let poll_client = client.clone();
let poll_db = db.clone();
let poll_fp = our_fp.clone();
tokio::spawn(async move {
network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await;
});
loop {
terminal.draw(|frame| app.draw(frame))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Enter {
app.handle_send(&identity, &db, &client).await;
app.scroll_offset = 0;
} else {
app.handle_key_event(key);
}
}
}
if app.should_quit {
break;
}
}
ratatui::restore();
Ok(())
}

View File

@@ -0,0 +1,538 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use sha2::{Sha256, Digest};
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::{ReceiptType, WireMessage};
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::types::Fingerprint;
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{ChatLine, PendingFileTransfer, ReceiptStatus, normfp};
/// Send a delivery receipt for a message back to its sender.
fn send_receipt(
our_fp: &str,
sender_fp: &str,
message_id: &str,
receipt_type: ReceiptType,
client: &ServerClient,
) {
let receipt = WireMessage::Receipt {
sender_fingerprint: our_fp.to_string(),
message_id: message_id.to_string(),
receipt_type,
};
let encoded = match bincode::serialize(&receipt) {
Ok(e) => e,
Err(_) => return,
};
let client = client.clone();
let to = sender_fp.to_string();
let from = our_fp.to_string();
tokio::spawn(async move {
let _ = client.send_message(&to, Some(&from), &encoded).await;
});
}
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
let _ = db.touch_contact(sender_fp, None);
let _ = db.store_message(sender_fp, sender_fp, text, false);
}
/// Process a single incoming raw message (shared by WS and HTTP paths).
pub fn process_incoming(
raw: &[u8],
identity: &IdentityKeyPair,
db: &LocalDb,
messages: &Arc<Mutex<Vec<ChatLine>>>,
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: &str,
client: &ServerClient,
last_dm_peer: &Arc<Mutex<Option<String>>>,
) {
match bincode::deserialize::<WireMessage>(raw) {
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer),
Err(_) => {}
}
}
fn process_wire_message(
wire: WireMessage,
identity: &IdentityKeyPair,
db: &LocalDb,
messages: &Arc<Mutex<Vec<ChatLine>>>,
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: &str,
client: &ServerClient,
last_dm_peer: &Arc<Mutex<Option<String>>>,
) {
match wire {
WireMessage::KeyExchange {
id,
sender_fingerprint,
sender_identity_encryption_key,
ephemeral_public,
used_one_time_pre_key_id,
ratchet_message,
} => {
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => return,
};
let spk_secret = match db.load_signed_pre_key(1) {
Ok(Some(s)) => s,
_ => return,
};
let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id {
db.take_one_time_pre_key(otpk_id).ok().flatten()
} else {
None
};
let their_id_x25519 = PublicKey::from(sender_identity_encryption_key);
let their_eph = PublicKey::from(ephemeral_public);
let shared_secret = match x3dh::respond(
identity, &spk_secret, otpk_secret.as_ref(), &their_id_x25519, &their_eph,
) {
Ok(s) => s,
Err(_) => return,
};
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
text,
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
});
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
// Terminal bell for incoming DM
print!("\x07");
}
Err(e) => {
// Session auto-recovery: delete corrupted session, show warning
let _ = db.delete_session(&sender_fp);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
}
}
}
WireMessage::Message {
id,
sender_fingerprint,
ratchet_message,
} => {
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => return,
};
let mut state = match db.load_session(&sender_fp) {
Ok(Some(s)) => s,
_ => return,
};
match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
text,
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
});
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
// Terminal bell for incoming DM
print!("\x07");
}
Err(e) => {
// Session auto-recovery: delete corrupted session, show warning
let _ = db.delete_session(&sender_fp);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
}
}
}
WireMessage::Receipt {
sender_fingerprint: _,
message_id,
receipt_type,
} => {
// Update receipt status for the referenced message
let mut r = receipts.lock().unwrap();
let current = r.get(&message_id);
let should_update = match (&receipt_type, current) {
(ReceiptType::Read, _) => true,
(ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true,
(ReceiptType::Delivered, None) => true,
_ => false,
};
if should_update {
let new_status = match receipt_type {
ReceiptType::Delivered => ReceiptStatus::Delivered,
ReceiptType::Read => ReceiptStatus::Read,
};
r.insert(message_id, new_status);
}
}
WireMessage::FileHeader {
id,
sender_fingerprint,
filename,
file_size,
total_chunks,
sha256,
} => {
let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)];
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Incoming file '{}' from {} ({} bytes, {} chunks)",
filename, short_sender, file_size, total_chunks
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
let transfer = PendingFileTransfer {
filename,
total_chunks,
received: 0,
chunks: vec![None; total_chunks as usize],
sha256,
file_size,
};
pending_files.lock().unwrap().insert(id, transfer);
}
WireMessage::FileChunk {
id,
sender_fingerprint,
filename: _,
chunk_index,
total_chunks: _,
data,
} => {
// Decrypt the chunk data using our ratchet session with the sender
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => return,
};
let mut state = match db.load_session(&sender_fp) {
Ok(Some(s)) => s,
_ => return,
};
// The data field is a bincode-serialized RatchetMessage
let ratchet_msg = match bincode::deserialize(&data) {
Ok(m) => m,
Err(_) => return,
};
let plaintext = match state.decrypt(&ratchet_msg) {
Ok(pt) => {
let _ = db.save_session(&sender_fp, &state);
pt
}
Err(_) => return,
};
let mut pf = pending_files.lock().unwrap();
if let Some(transfer) = pf.get_mut(&id) {
if (chunk_index as usize) < transfer.chunks.len() {
if transfer.chunks[chunk_index as usize].is_none() {
transfer.chunks[chunk_index as usize] = Some(plaintext);
transfer.received += 1;
}
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Receiving {} [{}/{}]...",
transfer.filename, transfer.received, transfer.total_chunks
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
// Check if all chunks received
if transfer.received == transfer.total_chunks {
let mut assembled = Vec::with_capacity(transfer.file_size as usize);
for chunk in &transfer.chunks {
if let Some(data) = chunk {
assembled.extend_from_slice(data);
}
}
// Verify SHA-256
let mut hasher = Sha256::new();
hasher.update(&assembled);
let computed_hash = format!("{:x}", hasher.finalize());
if computed_hash != transfer.sha256 {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"File '{}' integrity check FAILED (hash mismatch)",
transfer.filename
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
} else {
// Save to data_dir/downloads/
let download_dir = crate::keystore::data_dir().join("downloads");
let _ = std::fs::create_dir_all(&download_dir);
let save_path = download_dir.join(&transfer.filename);
match std::fs::write(&save_path, &assembled) {
Ok(_) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"File saved: {}",
save_path.display()
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
Err(e) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("Failed to save file: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
}
}
// Remove completed transfer
pf.remove(&id);
}
}
} else {
// Received chunk without header — ignore
}
}
WireMessage::GroupSenderKey {
id: _,
sender_fingerprint,
group_name,
generation,
counter,
ciphertext,
} => {
match db.load_sender_key(&sender_fingerprint, &group_name) {
Ok(Some(mut sender_key)) => {
let msg = warzone_protocol::sender_keys::SenderKeyMessage {
sender_fingerprint: sender_fingerprint.clone(),
group_name: group_name.clone(),
generation,
counter,
ciphertext,
};
match sender_key.decrypt(&msg) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
// Save updated sender key (counter advanced)
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: format!(
"{} [#{}]",
&sender_fingerprint[..sender_fingerprint.len().min(12)],
group_name
),
text,
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
Err(e) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[group #{}] decrypt failed from {}: {}",
group_name,
&sender_fingerprint[..sender_fingerprint.len().min(12)],
e
),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
}
}
_ => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[group #{}] no sender key for {} — key distribution needed",
group_name,
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
}
}
WireMessage::SenderKeyDistribution {
sender_fingerprint,
group_name,
chain_key,
generation,
} => {
let dist = warzone_protocol::sender_keys::SenderKeyDistribution {
sender_fingerprint: sender_fingerprint.clone(),
group_name: group_name.clone(),
chain_key,
generation,
};
let sender_key = dist.into_sender_key();
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Received sender key from {} for #{}",
&sender_fingerprint[..sender_fingerprint.len().min(12)],
group_name
),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
WireMessage::CallSignal {
id: _,
sender_fingerprint,
signal_type,
payload: _,
target: _,
} => {
let type_str = format!("{:?}", signal_type);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
text: format!("\u{1f4de} Call signal: {}", type_str),
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
}
}
/// Real-time message loop via WebSocket (falls back to HTTP polling).
pub async fn poll_loop(
messages: Arc<Mutex<Vec<ChatLine>>>,
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: String,
identity: IdentityKeyPair,
db: Arc<LocalDb>,
client: ServerClient,
last_dm_peer: Arc<Mutex<Option<String>>>,
connected: Arc<AtomicBool>,
) {
let fp = normfp(&our_fp);
// Try WebSocket first
let ws_url = client.base_url
.replace("http://", "ws://")
.replace("https://", "wss://");
let ws_url = format!("{}/v1/ws/{}", ws_url, fp);
loop {
match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws_stream, _)) => {
connected.store(true, Ordering::Relaxed);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Real-time connection established".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
use futures_util::StreamExt;
let (_, mut read) = ws_stream.split();
while let Some(Ok(msg)) = read.next().await {
if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg {
process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer);
}
}
connected.store(false, Ordering::Relaxed);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Connection lost, reconnecting...".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
tokio::time::sleep(Duration::from_secs(3)).await;
}
Err(_) => {
connected.store(false, Ordering::Relaxed);
// Fallback to HTTP polling
tokio::time::sleep(Duration::from_secs(2)).await;
let raw_msgs = match client.poll_messages(&our_fp).await {
Ok(m) => m,
Err(_) => continue,
};
for raw in &raw_msgs {
process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer);
}
}
}
}
}

View File

@@ -0,0 +1,220 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Local};
/// Maximum file size: 10 MB.
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
/// Chunk size: 64 KB.
pub const CHUNK_SIZE: usize = 64 * 1024;
/// State for tracking an incoming chunked file transfer.
#[derive(Clone)]
pub struct PendingFileTransfer {
pub filename: String,
pub total_chunks: u32,
pub received: u32,
pub chunks: Vec<Option<Vec<u8>>>,
pub sha256: String,
pub file_size: u64,
}
/// Receipt status for a sent message.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReceiptStatus {
Sent,
Delivered,
Read,
}
pub struct App {
pub input: String,
pub messages: Arc<Mutex<Vec<ChatLine>>>,
pub our_fp: String,
pub peer_fp: Option<String>,
pub server_url: String,
pub should_quit: bool,
pub cursor_pos: usize,
pub last_dm_peer: Arc<Mutex<Option<String>>>,
/// Track receipt status for messages we sent, keyed by message ID.
pub receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
/// Pending incoming file transfers, keyed by file ID.
pub pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
/// Scroll offset from bottom (0 = pinned to newest).
pub scroll_offset: usize,
/// Whether the WebSocket connection is active.
pub connected: Arc<AtomicBool>,
}
#[derive(Clone)]
pub struct ChatLine {
pub sender: String,
pub text: String,
pub is_system: bool,
pub is_self: bool,
/// Message ID (for sent messages, used to track receipts).
pub message_id: Option<String>,
/// When this message was created/received.
pub timestamp: DateTime<Local>,
}
impl App {
pub fn new(our_fp: String, peer_fp: Option<String>, server_url: String) -> Self {
let messages = Arc::new(Mutex::new(vec![ChatLine {
sender: "system".into(),
text: format!("You are {}", our_fp),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
}]));
if let Some(ref peer) = peer_fp {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("Chatting with {}", peer),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
} else {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
App {
input: String::new(),
messages,
our_fp,
peer_fp,
server_url,
should_quit: false,
last_dm_peer: Arc::new(Mutex::new(None)),
cursor_pos: 0,
receipts: Arc::new(Mutex::new(HashMap::new())),
pending_files: Arc::new(Mutex::new(HashMap::new())),
scroll_offset: 0,
connected: Arc::new(AtomicBool::new(false)),
}
}
pub fn add_message(&self, line: ChatLine) {
self.messages.lock().unwrap().push(line);
}
}
pub fn normfp(fp: &str) -> String {
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
#[test]
fn app_new_initializes_scroll_offset_to_zero() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn app_new_initializes_connected_to_false() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert!(!app.connected.load(Ordering::Relaxed));
}
#[test]
fn app_new_creates_system_messages() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
let msgs = app.messages.lock().unwrap();
assert!(msgs.len() >= 2);
assert!(msgs[0].is_system);
assert!(msgs[0].text.contains("aabbcc"));
}
#[test]
fn app_new_with_peer_shows_chatting_message() {
let app = App::new("aabbcc".into(), Some("ddeeff".into()), "http://localhost:7700".into());
let msgs = app.messages.lock().unwrap();
let has_chatting = msgs.iter().any(|m| m.text.contains("Chatting with") && m.text.contains("ddeeff"));
assert!(has_chatting);
}
#[test]
fn app_new_without_peer_shows_no_peer_message() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
let msgs = app.messages.lock().unwrap();
let has_no_peer = msgs.iter().any(|m| m.text.contains("No peer set"));
assert!(has_no_peer);
}
#[test]
fn chatline_has_timestamp() {
let line = ChatLine {
sender: "test".into(),
text: "hello".into(),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
};
// Timestamp should be within the last second
let elapsed = Local::now().signed_duration_since(line.timestamp);
assert!(elapsed.num_seconds() < 2);
}
#[test]
fn add_message_appends_to_list() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
let initial_count = app.messages.lock().unwrap().len();
app.add_message(ChatLine {
sender: "test".into(),
text: "new message".into(),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
let new_count = app.messages.lock().unwrap().len();
assert_eq!(new_count, initial_count + 1);
}
#[test]
fn normfp_strips_non_hex_and_lowercases() {
assert_eq!(normfp("AA-BB-CC"), "aabbcc");
assert_eq!(normfp("0x1234ABCD"), "01234abcd");
assert_eq!(normfp("hello"), "e"); // only 'e' is hex
assert_eq!(normfp("AABB"), "aabb");
}
#[test]
fn app_new_cursor_pos_zero() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert_eq!(app.cursor_pos, 0);
assert!(app.input.is_empty());
}
#[test]
fn app_new_should_quit_false() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert!(!app.should_quit);
}
}

View File

@@ -25,3 +25,5 @@ rand.workspace = true
futures-util = "0.3" futures-util = "0.3"
ed25519-dalek.workspace = true ed25519-dalek.workspace = true
bincode.workspace = true bincode.workspace = true
sha2.workspace = true
reqwest = { workspace = true, features = ["rustls-tls", "json"] }

View File

@@ -0,0 +1,84 @@
//! Auth enforcement middleware: axum extractor that validates bearer tokens.
//!
//! Reads `Authorization: Bearer <token>` from request headers, validates via
//! [`crate::routes::auth::validate_token`], and returns the authenticated
//! fingerprint or a 401 rejection.
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
};
use crate::state::AppState;
/// Extractor that validates a bearer token and provides the authenticated fingerprint.
///
/// Place this as the **first** parameter in any handler that requires authentication.
/// The extractor will reject the request with 401 if the token is missing or invalid.
///
/// # Example
///
/// ```ignore
/// async fn my_handler(
/// auth: AuthFingerprint,
/// State(state): State<AppState>,
/// ) -> impl IntoResponse {
/// let fp = auth.fingerprint; // guaranteed valid
/// // ...
/// }
/// ```
pub struct AuthFingerprint {
pub fingerprint: String,
}
#[axum::async_trait]
impl FromRequestParts<AppState> for AuthFingerprint {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let header = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|s| s.trim().to_string());
let token = match header {
Some(t) if !t.is_empty() => t,
_ => return Err(AuthError::MissingToken),
};
match crate::routes::auth::validate_token(&state.db.tokens, &token) {
Some(fingerprint) => Ok(AuthFingerprint { fingerprint }),
None => Err(AuthError::InvalidToken),
}
}
}
/// Rejection type for [`AuthFingerprint`] extractor failures.
pub enum AuthError {
/// No `Authorization: Bearer <token>` header was present (or it was empty).
MissingToken,
/// The token was present but did not pass validation (expired or unknown).
InvalidToken,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, msg) = match self {
AuthError::MissingToken => (
StatusCode::UNAUTHORIZED,
"missing or empty Authorization: Bearer <token> header",
),
AuthError::InvalidToken => (
StatusCode::UNAUTHORIZED,
"invalid or expired token",
),
};
(status, axum::Json(serde_json::json!({ "error": msg }))).into_response()
}
}

View File

@@ -6,6 +6,8 @@ pub struct Database {
pub groups: sled::Tree, pub groups: sled::Tree,
pub aliases: sled::Tree, pub aliases: sled::Tree,
pub tokens: sled::Tree, pub tokens: sled::Tree,
pub calls: sled::Tree,
pub missed_calls: sled::Tree,
_db: sled::Db, _db: sled::Db,
} }
@@ -17,12 +19,16 @@ impl Database {
let groups = db.open_tree("groups")?; let groups = db.open_tree("groups")?;
let aliases = db.open_tree("aliases")?; let aliases = db.open_tree("aliases")?;
let tokens = db.open_tree("tokens")?; let tokens = db.open_tree("tokens")?;
let calls = db.open_tree("calls")?;
let missed_calls = db.open_tree("missed_calls")?;
Ok(Database { Ok(Database {
keys, keys,
messages, messages,
groups, groups,
aliases, aliases,
tokens, tokens,
calls,
missed_calls,
_db: db, _db: db,
}) })
} }

View File

@@ -0,0 +1,212 @@
//! Federation: two-server message relay with shared-secret authentication.
//!
//! Each server periodically announces its connected clients to the peer.
//! When a message is destined for a remote client, it's forwarded via HTTP.
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
use sha2::{Sha256, Digest};
/// Federation configuration loaded from JSON.
#[derive(Clone, Debug, serde::Deserialize)]
pub struct FederationConfig {
pub server_id: String,
pub shared_secret: String,
pub peer: PeerConfig,
#[serde(default = "default_interval")]
pub presence_interval_secs: u64,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct PeerConfig {
pub id: String,
pub url: String,
}
fn default_interval() -> u64 { 5 }
/// Load federation config from a JSON file. Returns None if path is empty.
pub fn load_config(path: &str) -> anyhow::Result<FederationConfig> {
let data = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("failed to read federation config '{}': {}", path, e))?;
let config: FederationConfig = serde_json::from_str(&data)
.map_err(|e| anyhow::anyhow!("invalid federation config: {}", e))?;
Ok(config)
}
/// Remote presence: which fingerprints are on the peer server.
#[derive(Clone, Debug)]
pub struct RemotePresence {
pub peer_url: String,
pub peer_id: String,
pub fingerprints: HashSet<String>,
pub last_updated: i64,
}
impl RemotePresence {
pub fn new(peer_url: String, peer_id: String) -> Self {
RemotePresence {
peer_url,
peer_id,
fingerprints: HashSet::new(),
last_updated: 0,
}
}
/// Check if a fingerprint is on the remote server.
pub fn contains(&self, fp: &str) -> bool {
self.fingerprints.contains(fp)
}
/// Is the peer still alive? (heard from within 3 intervals)
pub fn is_alive(&self, interval_secs: u64) -> bool {
let now = chrono::Utc::now().timestamp();
now - self.last_updated < (interval_secs as i64 * 3)
}
}
/// Handle for communicating with the federation peer.
#[derive(Clone)]
pub struct FederationHandle {
pub config: FederationConfig,
pub client: reqwest::Client,
pub remote_presence: Arc<Mutex<RemotePresence>>,
}
impl FederationHandle {
pub fn new(config: FederationConfig) -> Self {
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
config.peer.url.clone(),
config.peer.id.clone(),
)));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.expect("failed to build HTTP client");
FederationHandle { config, client, remote_presence }
}
/// Check if a fingerprint is known to be on the peer server.
pub async fn is_remote(&self, fp: &str) -> bool {
let rp = self.remote_presence.lock().await;
rp.is_alive(self.config.presence_interval_secs) && rp.contains(fp)
}
/// Forward a message to the peer server for delivery.
/// Returns true if the peer accepted it.
pub async fn forward_message(&self, to_fp: &str, message: &[u8]) -> bool {
let url = format!("{}/v1/federation/forward", self.config.peer.url);
let body = serde_json::json!({
"to": to_fp,
"message": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, message),
"from_server": self.config.server_id,
});
let body_str = serde_json::to_string(&body).unwrap_or_default();
let token = compute_token(&self.config.shared_secret, body_str.as_bytes());
match self.client.post(&url)
.header("X-Federation-Token", &token)
.header("Content-Type", "application/json")
.body(body_str)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
tracing::debug!("Federation: forwarded message to {} for {}", self.config.peer.id, to_fp);
true
}
Ok(resp) => {
tracing::warn!("Federation: peer {} rejected forward: {}", self.config.peer.id, resp.status());
false
}
Err(e) => {
tracing::warn!("Federation: failed to forward to {}: {}", self.config.peer.id, e);
false
}
}
}
/// Send our local presence to the peer.
pub async fn announce_presence(&self, fingerprints: Vec<String>) -> bool {
let url = format!("{}/v1/federation/presence", self.config.peer.url);
let body = serde_json::json!({
"server_id": self.config.server_id,
"fingerprints": fingerprints,
"timestamp": chrono::Utc::now().timestamp(),
});
let body_str = serde_json::to_string(&body).unwrap_or_default();
let token = compute_token(&self.config.shared_secret, body_str.as_bytes());
match self.client.post(&url)
.header("X-Federation-Token", &token)
.header("Content-Type", "application/json")
.body(body_str)
.send()
.await
{
Ok(resp) if resp.status().is_success() => true,
Ok(resp) => {
tracing::warn!("Federation: presence announce to {} failed: {}", self.config.peer.id, resp.status());
false
}
Err(e) => {
tracing::warn!("Federation: presence announce to {} error: {}", self.config.peer.id, e);
false
}
}
}
}
/// Background task: periodically sync presence with peer.
pub async fn presence_sync_loop(
handle: FederationHandle,
connections: crate::state::Connections,
) {
let interval = std::time::Duration::from_secs(handle.config.presence_interval_secs);
tracing::info!(
"Federation: presence sync started (peer={}, interval={}s)",
handle.config.peer.id, handle.config.presence_interval_secs
);
loop {
// Collect local fingerprints
let fps: Vec<String> = {
let conns = connections.lock().await;
conns.keys().cloned().collect()
};
// Announce to peer
let ok = handle.announce_presence(fps.clone()).await;
if ok {
tracing::debug!("Federation: announced {} fingerprints to {}", fps.len(), handle.config.peer.id);
}
// Clear stale remote presence if peer hasn't responded
{
let mut rp = handle.remote_presence.lock().await;
if !rp.is_alive(handle.config.presence_interval_secs) && !rp.fingerprints.is_empty() {
tracing::warn!("Federation: peer {} stale — clearing remote presence ({} fps)",
handle.config.peer.id, rp.fingerprints.len());
rp.fingerprints.clear();
}
}
tokio::time::sleep(interval).await;
}
}
/// Compute an auth token: SHA-256(secret || body). Simple HMAC-like construction.
pub fn compute_token(secret: &str, body: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
hasher.update(body);
hex::encode(hasher.finalize())
}
/// Verify an auth token.
pub fn verify_token(secret: &str, body: &[u8], token: &str) -> bool {
let expected = compute_token(secret, body);
// Constant-time comparison to prevent timing attacks
expected.len() == token.len() && expected.as_bytes().iter().zip(token.as_bytes()).all(|(a, b)| a == b)
}

View File

@@ -1,5 +1,7 @@
pub mod auth_middleware;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod errors; pub mod errors;
pub mod federation;
pub mod routes; pub mod routes;
pub mod state; pub mod state;

View File

@@ -1,8 +1,10 @@
use clap::Parser; use clap::Parser;
pub mod auth_middleware;
mod config; mod config;
mod db; mod db;
mod errors; mod errors;
mod federation;
mod routes; mod routes;
mod state; mod state;
@@ -16,6 +18,10 @@ struct Cli {
/// Database directory /// Database directory
#[arg(short, long, default_value = "./warzone-data")] #[arg(short, long, default_value = "./warzone-data")]
data_dir: String, data_dir: String,
/// Federation config file (JSON). Enables server-to-server message relay.
#[arg(short, long)]
federation: Option<String>,
} }
#[tokio::main] #[tokio::main]
@@ -30,11 +36,38 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
tracing::info!("Warzone server starting on {}", cli.bind); tracing::info!("Warzone server starting on {}", cli.bind);
let state = state::AppState::new(&cli.data_dir)?; let mut state = state::AppState::new(&cli.data_dir)?;
// Load federation config if provided
if let Some(ref fed_path) = cli.federation {
let fed_config = federation::load_config(fed_path)?;
tracing::info!(
"Federation enabled: server_id={}, peer={}@{}",
fed_config.server_id, fed_config.peer.id, fed_config.peer.url
);
let handle = federation::FederationHandle::new(fed_config);
state.federation = Some(handle);
}
// Spawn federation presence sync if enabled
if let Some(ref federation) = state.federation {
let handle = federation.clone();
let connections = state.connections.clone();
tokio::spawn(async move {
federation::presence_sync_loop(handle, connections).await;
});
}
let cors = tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any);
let app = axum::Router::new() let app = axum::Router::new()
.merge(routes::web_router()) .merge(routes::web_router())
.nest("/v1", routes::router()) .nest("/v1", routes::router())
.layer(cors)
.layer(tower::limit::ConcurrencyLimitLayer::new(200))
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state); .with_state(state);

View File

@@ -112,6 +112,7 @@ struct RegisterRequest {
/// - Expired aliases (past grace period) can be reclaimed by anyone /// - Expired aliases (past grace period) can be reclaimed by anyone
/// - Expired aliases (within grace period) can only be reclaimed by recovery key /// - Expired aliases (within grace period) can only be reclaimed by recovery key
async fn register_alias( async fn register_alias(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RegisterRequest>, Json(req): Json<RegisterRequest>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
@@ -190,6 +191,7 @@ struct RecoverRequest {
/// Recover an alias using the recovery key. Works even if expired (within or past grace). /// Recover an alias using the recovery key. Works even if expired (within or past grace).
async fn recover_alias( async fn recover_alias(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RecoverRequest>, Json(req): Json<RecoverRequest>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
@@ -244,6 +246,7 @@ struct RenewRequest {
/// Renew/heartbeat — resets the TTL. Called automatically on activity. /// Renew/heartbeat — resets the TTL. Called automatically on activity.
async fn renew_alias( async fn renew_alias(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RenewRequest>, Json(req): Json<RenewRequest>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
@@ -347,6 +350,7 @@ struct UnregisterRequest {
/// Remove your own alias. /// Remove your own alias.
async fn unregister_alias( async fn unregister_alias(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<UnregisterRequest>, Json(req): Json<UnregisterRequest>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
@@ -381,6 +385,7 @@ struct AdminRemoveRequest {
/// Admin: remove any alias. /// Admin: remove any alias.
async fn admin_remove_alias( async fn admin_remove_alias(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<AdminRemoveRequest>, Json(req): Json<AdminRemoveRequest>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {

View File

@@ -0,0 +1,233 @@
use axum::{
extract::{Path, Query, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use sha2::{Sha256, Digest};
use crate::errors::AppResult;
use crate::state::{AppState, CallState, CallStatus};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/calls/initiate", post(initiate_call))
.route("/calls/:id", get(get_call))
.route("/calls/:id/end", post(end_call))
.route("/calls/active", get(active_calls))
.route("/calls/missed", post(get_missed_calls))
.route("/groups/:name/call", post(initiate_group_call))
}
fn normalize_fp(fp: &str) -> String {
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
}
#[derive(Deserialize)]
struct InitiateRequest {
caller: String,
callee: String,
}
async fn initiate_call(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<InitiateRequest>,
) -> AppResult<Json<serde_json::Value>> {
let call_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().timestamp();
let call = CallState {
call_id: call_id.clone(),
caller_fp: normalize_fp(&req.caller),
callee_fp: normalize_fp(&req.callee),
group_name: None,
room_id: None,
status: CallStatus::Ringing,
created_at: now,
answered_at: None,
ended_at: None,
};
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
tracing::info!("Call initiated: {} -> {}", call.caller_fp, call.callee_fp);
Ok(Json(serde_json::json!({
"call_id": call_id,
"status": "ringing",
})))
}
async fn get_call(
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
// Try in-memory first, then DB
if let Some(call) = state.active_calls.lock().await.get(&id) {
return Ok(Json(serde_json::to_value(call)?));
}
if let Some(data) = state.db.calls.get(id.as_bytes())? {
let call: CallState = serde_json::from_slice(&data)?;
return Ok(Json(serde_json::to_value(&call)?));
}
Ok(Json(serde_json::json!({ "error": "call not found" })))
}
#[derive(Deserialize)]
struct EndCallRequest {
fingerprint: String,
}
async fn end_call(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<EndCallRequest>,
) -> AppResult<Json<serde_json::Value>> {
let now = chrono::Utc::now().timestamp();
let _fp = normalize_fp(&req.fingerprint);
let mut calls = state.active_calls.lock().await;
if let Some(mut call) = calls.remove(&id) {
call.status = CallStatus::Ended;
call.ended_at = Some(now);
state.db.calls.insert(id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
return Ok(Json(serde_json::json!({ "ok": true, "call_id": id })));
}
Ok(Json(serde_json::json!({ "error": "call not found or already ended" })))
}
#[derive(Deserialize)]
struct ActiveQuery {
fingerprint: Option<String>,
}
async fn active_calls(
State(state): State<AppState>,
Query(q): Query<ActiveQuery>,
) -> AppResult<Json<serde_json::Value>> {
let calls = state.active_calls.lock().await;
let filtered: Vec<&CallState> = match q.fingerprint {
Some(ref fp) => {
let fp = normalize_fp(fp);
calls.values().filter(|c| c.caller_fp == fp || c.callee_fp == fp).collect()
}
None => calls.values().collect(),
};
Ok(Json(serde_json::json!({ "calls": filtered })))
}
#[derive(Deserialize)]
struct MissedRequest {
fingerprint: String,
}
async fn get_missed_calls(
State(state): State<AppState>,
Json(req): Json<MissedRequest>,
) -> AppResult<Json<serde_json::Value>> {
let fp = normalize_fp(&req.fingerprint);
let prefix = format!("missed:{}", fp);
let mut missed = Vec::new();
let mut keys = Vec::new();
for (key, value) in state.db.missed_calls.scan_prefix(prefix.as_bytes()).flatten() {
if let Ok(record) = serde_json::from_slice::<serde_json::Value>(&value) {
missed.push(record);
keys.push(key);
}
}
// Delete after reading
for key in &keys {
let _ = state.db.missed_calls.remove(key);
}
Ok(Json(serde_json::json!({ "missed_calls": missed })))
}
// --- FC-5: Group call ---
#[derive(Deserialize)]
struct GroupCallRequest {
fingerprint: String,
}
/// Deterministic room ID from group name: hex(SHA-256("featherchat-group:" + name)[:16])
fn hash_room_name(group_name: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(format!("featherchat-group:{}", group_name).as_bytes());
let hash = hasher.finalize();
hex::encode(&hash[..16])
}
async fn initiate_group_call(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<GroupCallRequest>,
) -> AppResult<Json<serde_json::Value>> {
let caller_fp = normalize_fp(&req.fingerprint);
// Load group
let group_data = match state.db.groups.get(name.as_bytes())? {
Some(d) => d,
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
let group: serde_json::Value = serde_json::from_slice(&group_data)?;
let members: Vec<String> = group.get("members")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
// Verify caller is a member
if !members.contains(&caller_fp) {
return Ok(Json(serde_json::json!({ "error": "not a member of this group" })));
}
let room_id = hash_room_name(&name);
let call_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().timestamp();
// Create call state
let call = CallState {
call_id: call_id.clone(),
caller_fp: caller_fp.clone(),
callee_fp: "group".to_string(),
group_name: Some(name.clone()),
room_id: Some(room_id.clone()),
status: CallStatus::Ringing,
created_at: now,
answered_at: None,
ended_at: None,
};
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
// Fan out CallSignal::Offer to all online members (except caller)
let offer = warzone_protocol::message::WireMessage::CallSignal {
id: call_id.clone(),
sender_fingerprint: caller_fp.clone(),
signal_type: warzone_protocol::message::CallSignalType::Offer,
payload: serde_json::json!({ "room_id": room_id, "group": name }).to_string(),
target: format!("#{}", name),
};
let encoded = bincode::serialize(&offer)?;
let mut delivered = 0;
for member in &members {
if *member == caller_fp { continue; }
if state.push_to_client(member, &encoded).await {
delivered += 1;
} else {
// Queue for offline members
let key = format!("queue:{}:{}", member, uuid::Uuid::new_v4());
state.db.messages.insert(key.as_bytes(), encoded.as_slice())?;
}
}
tracing::info!("Group call #{}: room={}, caller={}, notified={}/{}",
name, room_id, caller_fp, delivered, members.len() - 1);
Ok(Json(serde_json::json!({
"call_id": call_id,
"room_id": room_id,
"group": name,
"members_notified": delivered,
"members_total": members.len() - 1,
})))
}

View File

@@ -0,0 +1,102 @@
use axum::{
extract::State,
routing::{get, post},
Json, Router,
};
use crate::auth_middleware::AuthFingerprint;
use crate::errors::AppResult;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/devices", get(list_devices))
.route("/devices/:id/kick", post(kick_device))
.route("/devices/revoke-all", post(revoke_all))
}
/// List active WS connections for the authenticated user.
async fn list_devices(
auth: AuthFingerprint,
State(state): State<AppState>,
) -> AppResult<Json<serde_json::Value>> {
let devices = state.list_devices(&auth.fingerprint).await;
let list: Vec<serde_json::Value> = devices
.iter()
.map(|(id, connected_at)| {
serde_json::json!({
"device_id": id,
"connected_at": connected_at,
})
})
.collect();
let count = list.len();
Ok(Json(serde_json::json!({
"fingerprint": auth.fingerprint,
"devices": list,
"count": count,
})))
}
/// Kick a specific device by ID. Requires auth -- only the device owner can kick.
async fn kick_device(
auth: AuthFingerprint,
State(state): State<AppState>,
axum::extract::Path(device_id): axum::extract::Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let kicked = state.kick_device(&auth.fingerprint, &device_id).await;
if kicked {
tracing::info!("Device {} kicked by {}", device_id, auth.fingerprint);
Ok(Json(serde_json::json!({ "ok": true, "kicked": device_id })))
} else {
Ok(Json(serde_json::json!({ "error": "device not found" })))
}
}
/// Revoke all sessions except the current one. Panic button.
async fn revoke_all(
auth: AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<serde_json::Value>,
) -> AppResult<Json<serde_json::Value>> {
let keep_device = req
.get("keep_device_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let removed = state
.revoke_all_except(&auth.fingerprint, keep_device)
.await;
// Also clear all tokens for this fingerprint except the current one
// Scan tokens tree for this fingerprint
let mut tokens_to_remove = Vec::new();
for item in state.db.tokens.iter().flatten() {
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&item.1) {
if val.get("fingerprint").and_then(|v| v.as_str()) == Some(&auth.fingerprint) {
tokens_to_remove.push(item.0.clone());
}
}
}
// Only remove tokens if we actually revoked devices
let tokens_cleared = if removed > 0 {
let count = tokens_to_remove.len();
for key in &tokens_to_remove {
let _ = state.db.tokens.remove(key);
}
count
} else {
0
};
tracing::info!(
"Revoke-all for {}: {} devices removed, {} tokens cleared",
auth.fingerprint,
removed,
tokens_cleared,
);
Ok(Json(serde_json::json!({
"ok": true,
"devices_removed": removed,
"tokens_cleared": tokens_cleared,
})))
}

View File

@@ -0,0 +1,144 @@
//! Federation route handlers: receive presence updates and forwarded messages from peer server.
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::post,
Json, Router,
};
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/federation/presence", post(receive_presence))
.route("/federation/forward", post(receive_forward))
.route("/federation/status", axum::routing::get(federation_status))
}
/// Extract and validate the federation token from headers.
fn validate_request(state: &AppState, headers: &HeaderMap, body: &[u8]) -> Result<(), (StatusCode, String)> {
let federation = state.federation.as_ref()
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "federation not configured".to_string()))?;
let token = headers.get("x-federation-token")
.and_then(|v| v.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "missing X-Federation-Token header".to_string()))?;
if !crate::federation::verify_token(&federation.config.shared_secret, body, token) {
return Err((StatusCode::UNAUTHORIZED, "invalid federation token".to_string()));
}
Ok(())
}
/// Receive presence announcement from peer.
/// POST /v1/federation/presence
/// Body: { "server_id": "...", "fingerprints": [...], "timestamp": ... }
async fn receive_presence(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
if let Err((status, msg)) = validate_request(&state, &headers, &body) {
return (status, Json(serde_json::json!({ "error": msg }))).into_response();
}
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid JSON: {}", e) }))).into_response(),
};
let fingerprints: Vec<String> = parsed.get("fingerprints")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown");
if let Some(ref federation) = state.federation {
let mut rp = federation.remote_presence.lock().await;
let count = fingerprints.len();
rp.fingerprints = fingerprints.into_iter().collect();
rp.last_updated = chrono::Utc::now().timestamp();
tracing::debug!("Federation: received {} fingerprints from {}", count, server_id);
}
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
}
/// Receive a forwarded message from peer.
/// POST /v1/federation/forward
/// Body: { "to": "fingerprint", "message": "base64...", "from_server": "..." }
async fn receive_forward(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
if let Err((status, msg)) = validate_request(&state, &headers, &body) {
return (status, Json(serde_json::json!({ "error": msg }))).into_response();
}
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid JSON: {}", e) }))).into_response(),
};
let to = match parsed.get("to").and_then(|v| v.as_str()) {
Some(fp) => fp.to_string(),
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "missing 'to' field" }))).into_response(),
};
let message_b64 = match parsed.get("message").and_then(|v| v.as_str()) {
Some(m) => m.to_string(),
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "missing 'message' field" }))).into_response(),
};
let message = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &message_b64) {
Ok(m) => m,
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid base64: {}", e) }))).into_response(),
};
let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("unknown");
// Try to deliver locally
let delivered = state.push_to_client(&to, &message).await;
if !delivered {
// Queue for later pickup
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
let _ = state.db.messages.insert(key.as_bytes(), message.as_slice());
tracing::info!("Federation: queued forwarded message from {} for offline user {}", from_server, to);
} else {
tracing::info!("Federation: delivered forwarded message from {} to {}", from_server, to);
}
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "delivered": delivered }))).into_response()
}
/// Federation health status.
/// GET /v1/federation/status
async fn federation_status(
State(state): State<AppState>,
) -> Json<serde_json::Value> {
match state.federation {
Some(ref federation) => {
let rp = federation.remote_presence.lock().await;
Json(serde_json::json!({
"enabled": true,
"server_id": federation.config.server_id,
"peer_id": federation.config.peer.id,
"peer_url": federation.config.peer.url,
"peer_alive": rp.is_alive(federation.config.presence_interval_secs),
"remote_clients": rp.fingerprints.len(),
"last_sync": rp.last_updated,
}))
}
None => {
Json(serde_json::json!({
"enabled": false,
}))
}
}
}

View File

@@ -75,6 +75,7 @@ fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> {
} }
async fn create_group( async fn create_group(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<CreateRequest>, Json(req): Json<CreateRequest>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
@@ -99,6 +100,7 @@ async fn create_group(
} }
async fn join_group( async fn join_group(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
Json(req): Json<JoinRequest>, Json(req): Json<JoinRequest>,
@@ -169,6 +171,7 @@ async fn list_groups(
/// queue infrastructure — group messages look like 1:1 messages to the /// queue infrastructure — group messages look like 1:1 messages to the
/// recipient, but with a group tag. /// recipient, but with a group tag.
async fn send_to_group( async fn send_to_group(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
Json(req): Json<GroupSendRequest>, Json(req): Json<GroupSendRequest>,
@@ -210,6 +213,7 @@ async fn send_to_group(
} }
async fn leave_group( async fn leave_group(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
Json(req): Json<JoinRequest>, Json(req): Json<JoinRequest>,
@@ -235,6 +239,7 @@ struct KickRequest {
} }
async fn kick_member( async fn kick_member(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
Json(req): Json<KickRequest>, Json(req): Json<KickRequest>,

View File

@@ -54,6 +54,7 @@ struct RegisterResponse {
} }
async fn register_keys( async fn register_keys(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RegisterRequest>, Json(req): Json<RegisterRequest>,
) -> Json<RegisterResponse> { ) -> Json<RegisterResponse> {
@@ -129,6 +130,7 @@ struct OtpkEntry {
/// Upload additional one-time pre-keys. /// Upload additional one-time pre-keys.
async fn replenish_otpks( async fn replenish_otpks(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<ReplenishRequest>, Json(req): Json<ReplenishRequest>,
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {

View File

@@ -71,6 +71,7 @@ fn normalize_fp(fp: &str) -> String {
} }
async fn send_message( async fn send_message(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<SendRequest>, Json(req): Json<SendRequest>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
@@ -84,14 +85,11 @@ async fn send_message(
} }
} }
// Try WebSocket push first (instant delivery) let delivered = state.deliver_or_queue(&to, &req.message).await;
if state.push_to_client(&to, &req.message).await { if delivered {
tracing::info!("Pushed message to {} via WS ({} bytes)", to, req.message.len()); tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len());
} else { } else {
// Queue in DB (offline delivery) tracing::info!("Queued message for {} ({} bytes)", to, req.message.len());
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
tracing::info!("Queuing message for {} ({} bytes)", to, req.message.len());
state.db.messages.insert(key.as_bytes(), req.message)?;
} }
// Renew sender's alias TTL (sending = authenticated action) // Renew sender's alias TTL (sending = authenticated action)

View File

@@ -1,11 +1,16 @@
mod aliases; mod aliases;
pub mod auth; pub mod auth;
mod calls;
mod devices;
mod federation;
mod groups; mod groups;
mod health; mod health;
mod keys; mod keys;
pub mod messages; pub mod messages;
mod presence;
mod web; mod web;
mod ws; mod ws;
mod wzp;
use axum::Router; use axum::Router;
@@ -20,6 +25,11 @@ pub fn router() -> Router<AppState> {
.merge(aliases::routes()) .merge(aliases::routes())
.merge(auth::routes()) .merge(auth::routes())
.merge(ws::routes()) .merge(ws::routes())
.merge(calls::routes())
.merge(devices::routes())
.merge(presence::routes())
.merge(wzp::routes())
.merge(federation::routes())
} }
/// Web UI router (served at root, outside /v1) /// Web UI router (served at root, outside /v1)

View File

@@ -0,0 +1,57 @@
use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use crate::errors::AppResult;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/presence/:fingerprint", get(get_presence))
.route("/presence/batch", post(batch_presence))
}
fn normalize_fp(fp: &str) -> String {
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
}
async fn get_presence(
State(state): State<AppState>,
Path(fingerprint): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let fp = normalize_fp(&fingerprint);
let online = state.is_online(&fp).await;
let devices = state.device_count(&fp).await;
Ok(Json(serde_json::json!({
"fingerprint": fp,
"online": online,
"devices": devices,
})))
}
#[derive(Deserialize)]
struct BatchRequest {
fingerprints: Vec<String>,
}
async fn batch_presence(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<BatchRequest>,
) -> AppResult<Json<serde_json::Value>> {
let mut results = Vec::new();
for fp in &req.fingerprints {
let fp = normalize_fp(fp);
let online = state.is_online(&fp).await;
let devices = state.device_count(&fp).await;
results.push(serde_json::json!({
"fingerprint": fp,
"online": online,
"devices": devices,
}));
}
Ok(Json(serde_json::json!({ "results": results })))
}

View File

@@ -66,16 +66,20 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
let (mut ws_tx, mut ws_rx) = socket.split(); let (mut ws_tx, mut ws_rx) = socket.split();
// Register for push delivery // Register for push delivery
let mut push_rx = state.register_ws(&fingerprint).await; let (_device_id, mut push_rx) = match state.register_ws(&fingerprint, None).await {
Some(pair) => pair,
None => {
tracing::warn!("WS {}: rejected — connection limit reached", fingerprint);
return; // closes the socket
}
};
// Send any queued messages from DB // Send any queued messages from DB
let prefix = format!("queue:{}", fingerprint); let prefix = format!("queue:{}", fingerprint);
let mut keys_to_delete = Vec::new(); let mut keys_to_delete = Vec::new();
for item in state.db.messages.scan_prefix(prefix.as_bytes()) { for (key, value) in state.db.messages.scan_prefix(prefix.as_bytes()).flatten() {
if let Ok((key, value)) = item { if ws_tx.send(Message::Binary(value.to_vec())).await.is_ok() {
if ws_tx.send(Message::Binary(value.to_vec().into())).await.is_ok() { keys_to_delete.push(key);
keys_to_delete.push(key);
}
} }
} }
for key in &keys_to_delete { for key in &keys_to_delete {
@@ -85,11 +89,34 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
tracing::info!("WS {}: flushed {} queued messages", fingerprint, keys_to_delete.len()); tracing::info!("WS {}: flushed {} queued messages", fingerprint, keys_to_delete.len());
} }
// Flush missed calls (FC-7)
let missed_prefix = format!("missed:{}", fingerprint);
let mut missed_keys = Vec::new();
for (key, value) in state.db.missed_calls.scan_prefix(missed_prefix.as_bytes()).flatten() {
if let Ok(missed) = serde_json::from_slice::<serde_json::Value>(&value) {
let wrapper = serde_json::json!({
"type": "missed_call",
"data": missed,
});
if let Ok(json_str) = serde_json::to_string(&wrapper) {
if ws_tx.send(Message::Text(json_str)).await.is_ok() {
missed_keys.push(key);
}
}
}
}
for key in &missed_keys {
let _ = state.db.missed_calls.remove(key);
}
if !missed_keys.is_empty() {
tracing::info!("WS {}: flushed {} missed call notifications", fingerprint, missed_keys.len());
}
// Spawn task to forward push messages to WS // Spawn task to forward push messages to WS
let _fp_clone = fingerprint.clone(); let _fp_clone = fingerprint.clone();
let mut push_task = tokio::spawn(async move { let mut push_task = tokio::spawn(async move {
while let Some(msg) = push_rx.recv().await { while let Some(msg) = push_rx.recv().await {
if ws_tx.send(Message::Binary(msg.into())).await.is_err() { if ws_tx.send(Message::Binary(msg)).await.is_err() {
break; break;
} }
} }
@@ -119,13 +146,77 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
} }
} }
// Try push to connected client first // Call signal side effects
if !state_clone.push_to_client(&to_fp, message).await { if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = bincode::deserialize::<WireMessage>(message) {
// Queue in DB use warzone_protocol::message::CallSignalType;
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); let now = chrono::Utc::now().timestamp();
let _ = state_clone.db.messages.insert(key.as_bytes(), message); match signal_type {
CallSignalType::Offer => {
let call = crate::state::CallState {
call_id: id.clone(),
caller_fp: sender_fingerprint.clone(),
callee_fp: to_fp.clone(),
group_name: None,
room_id: None,
status: crate::state::CallStatus::Ringing,
created_at: now,
answered_at: None,
ended_at: None,
};
state_clone.active_calls.lock().await.insert(id.clone(), call.clone());
// Persist to DB
let _ = state_clone.db.calls.insert(
id.as_bytes(),
serde_json::to_vec(&call).unwrap_or_default(),
);
tracing::info!("Call {} started: {} -> {}", id, sender_fingerprint, to_fp);
// If callee is offline, record missed call (FC-7)
if !state_clone.is_online(&to_fp).await {
let missed_key = format!("missed:{}:{}", to_fp, id);
let missed = serde_json::json!({
"call_id": id,
"caller_fp": sender_fingerprint,
"timestamp": now,
});
let _ = state_clone.db.missed_calls.insert(
missed_key.as_bytes(),
serde_json::to_vec(&missed).unwrap_or_default(),
);
tracing::info!("Missed call recorded for offline user {}", to_fp);
}
}
CallSignalType::Answer => {
let mut calls = state_clone.active_calls.lock().await;
if let Some(call) = calls.get_mut(id) {
call.status = crate::state::CallStatus::Active;
call.answered_at = Some(now);
let _ = state_clone.db.calls.insert(
id.as_bytes(),
serde_json::to_vec(&call).unwrap_or_default(),
);
}
tracing::info!("Call {} answered", id);
}
CallSignalType::Hangup | CallSignalType::Reject => {
let mut calls = state_clone.active_calls.lock().await;
if let Some(mut call) = calls.remove(id) {
call.status = crate::state::CallStatus::Ended;
call.ended_at = Some(now);
let _ = state_clone.db.calls.insert(
id.as_bytes(),
serde_json::to_vec(&call).unwrap_or_default(),
);
}
tracing::info!("Call {} ended", id);
}
_ => {} // Ringing, Busy, IceCandidate — route opaquely
}
} }
// Deliver via local WS, federation, or queue in DB
state_clone.deliver_or_queue(&to_fp, message).await;
tracing::debug!("WS {}: routed message to {}", fp_clone2, to_fp); tracing::debug!("WS {}: routed message to {}", fp_clone2, to_fp);
} }
} }
@@ -147,10 +238,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
} }
} }
if !state_clone.push_to_client(&to_fp, &message).await { // Deliver via local WS, federation, or queue in DB
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); state_clone.deliver_or_queue(&to_fp, &message).await;
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
}
// Renew alias TTL // Renew alias TTL
crate::routes::messages::renew_alias_ttl( crate::routes::messages::renew_alias_ttl(
@@ -181,9 +270,9 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
// We can't easily get the sender ref here, so just clean up by fingerprint // We can't easily get the sender ref here, so just clean up by fingerprint
// In production, use a unique connection ID // In production, use a unique connection ID
let mut conns = state.connections.lock().await; let mut conns = state.connections.lock().await;
if let Some(senders) = conns.get_mut(&fingerprint) { if let Some(devices) = conns.get_mut(&fingerprint) {
senders.retain(|s| !s.is_closed()); devices.retain(|d| !d.sender.is_closed());
if senders.is_empty() { if devices.is_empty() {
conns.remove(&fingerprint); conns.remove(&fingerprint);
} }
} }

View File

@@ -0,0 +1,45 @@
use axum::{
extract::State,
routing::get,
Json, Router,
};
use crate::errors::AppResult;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/wzp/relay-config", get(relay_config))
}
/// Returns the WZP relay address and a short-lived service token.
///
/// The web client calls this to discover where to connect for voice/video
/// and gets a token to present to the relay for authentication.
async fn relay_config(
State(state): State<AppState>,
) -> AppResult<Json<serde_json::Value>> {
// Issue a short-lived service token (5 minutes) for WZP relay auth.
let token = hex::encode(rand::random::<[u8; 32]>());
let expires = chrono::Utc::now().timestamp() + 300; // 5 minutes
state.db.tokens.insert(
token.as_bytes(),
serde_json::to_vec(&serde_json::json!({
"fingerprint": "service:wzp",
"service": "wzp",
"expires_at": expires,
}))?.as_slice(),
)?;
// The relay address is configured server-side. For now, return a
// placeholder that the admin sets via environment variable.
let relay_addr = std::env::var("WZP_RELAY_ADDR")
.unwrap_or_else(|_| "127.0.0.1:4433".to_string());
Ok(Json(serde_json::json!({
"relay_addr": relay_addr,
"token": token,
"expires_in": 300,
})))
}

View File

@@ -4,14 +4,26 @@ use tokio::sync::{Mutex, mpsc};
use crate::db::Database; use crate::db::Database;
/// Maximum WebSocket connections per fingerprint (multi-device cap).
const MAX_WS_PER_FINGERPRINT: usize = 5;
/// Maximum number of message IDs to track for deduplication. /// Maximum number of message IDs to track for deduplication.
const DEDUP_CAPACITY: usize = 10_000; const DEDUP_CAPACITY: usize = 10_000;
/// Per-connection sender: messages are pushed here for instant delivery. /// Per-connection sender: messages are pushed here for instant delivery.
pub type WsSender = mpsc::UnboundedSender<Vec<u8>>; pub type WsSender = mpsc::UnboundedSender<Vec<u8>>;
/// Connected clients: fingerprint → list of WS senders (multiple devices). /// Metadata for a single connected device.
pub type Connections = Arc<Mutex<HashMap<String, Vec<WsSender>>>>; #[derive(Clone)]
pub struct DeviceConnection {
pub device_id: String,
pub sender: WsSender,
pub connected_at: i64,
pub token: Option<String>,
}
/// Connected clients: fingerprint → list of device connections (multiple devices).
pub type Connections = Arc<Mutex<HashMap<String, Vec<DeviceConnection>>>>;
/// Bounded dedup tracker: FIFO eviction when capacity is exceeded. /// Bounded dedup tracker: FIFO eviction when capacity is exceeded.
#[derive(Clone)] #[derive(Clone)]
@@ -47,11 +59,35 @@ impl DedupTracker {
} }
} }
/// Call lifecycle status.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum CallStatus {
Ringing,
Active,
Ended,
}
/// Server-side state for an active or recently ended call.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CallState {
pub call_id: String,
pub caller_fp: String,
pub callee_fp: String,
pub group_name: Option<String>,
pub room_id: Option<String>,
pub status: CallStatus,
pub created_at: i64,
pub answered_at: Option<i64>,
pub ended_at: Option<i64>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: Arc<Database>, pub db: Arc<Database>,
pub connections: Connections, pub connections: Connections,
pub dedup: DedupTracker, pub dedup: DedupTracker,
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
pub federation: Option<crate::federation::FederationHandle>,
} }
impl AppState { impl AppState {
@@ -61,16 +97,18 @@ impl AppState {
db: Arc::new(db), db: Arc::new(db),
connections: Arc::new(Mutex::new(HashMap::new())), connections: Arc::new(Mutex::new(HashMap::new())),
dedup: DedupTracker::new(), dedup: DedupTracker::new(),
active_calls: Arc::new(Mutex::new(HashMap::new())),
federation: None,
}) })
} }
/// Try to push a message to a connected client. Returns true if delivered. /// Try to push a message to a connected client. Returns true if delivered.
pub async fn push_to_client(&self, fingerprint: &str, message: &[u8]) -> bool { pub async fn push_to_client(&self, fingerprint: &str, message: &[u8]) -> bool {
let conns = self.connections.lock().await; let conns = self.connections.lock().await;
if let Some(senders) = conns.get(fingerprint) { if let Some(devices) = conns.get(fingerprint) {
let mut delivered = false; let mut delivered = false;
for sender in senders { for device in devices {
if sender.send(message.to_vec()).is_ok() { if device.sender.send(message.to_vec()).is_ok() {
delivered = true; delivered = true;
} }
} }
@@ -81,25 +119,127 @@ impl AppState {
} }
/// Register a WS connection for a fingerprint. /// Register a WS connection for a fingerprint.
pub async fn register_ws(&self, fingerprint: &str) -> mpsc::UnboundedReceiver<Vec<u8>> { ///
/// Returns `None` if the per-fingerprint connection cap has been reached.
/// On success, returns the assigned device ID and a receiver for push messages.
pub async fn register_ws(&self, fingerprint: &str, token: Option<String>) -> Option<(String, mpsc::UnboundedReceiver<Vec<u8>>)> {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
let device_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
let mut conns = self.connections.lock().await; let mut conns = self.connections.lock().await;
conns.entry(fingerprint.to_string()).or_default().push(tx); let entry = conns.entry(fingerprint.to_string()).or_default();
tracing::info!("WS registered for {} ({} total connections)", fingerprint,
conns.values().map(|v| v.len()).sum::<usize>()); // Clean up closed connections first
rx entry.retain(|d| !d.sender.is_closed());
if entry.len() >= MAX_WS_PER_FINGERPRINT {
tracing::warn!(
"WS connection cap reached for {} ({} connections)",
fingerprint,
entry.len()
);
return None;
}
entry.push(DeviceConnection {
device_id: device_id.clone(),
sender: tx,
connected_at: chrono::Utc::now().timestamp(),
token,
});
tracing::info!(
"WS registered for {} device={} ({} total)",
fingerprint,
device_id,
conns.values().map(|v| v.len()).sum::<usize>()
);
Some((device_id, rx))
} }
/// Unregister a WS connection. /// Unregister a WS connection.
#[allow(dead_code)] #[allow(dead_code)]
pub async fn unregister_ws(&self, fingerprint: &str, sender: &WsSender) { pub async fn unregister_ws(&self, fingerprint: &str, sender: &WsSender) {
let mut conns = self.connections.lock().await; let mut conns = self.connections.lock().await;
if let Some(senders) = conns.get_mut(fingerprint) { if let Some(devices) = conns.get_mut(fingerprint) {
senders.retain(|s| !s.same_channel(sender)); devices.retain(|d| !d.sender.same_channel(sender));
if senders.is_empty() { if devices.is_empty() {
conns.remove(fingerprint); conns.remove(fingerprint);
} }
} }
tracing::info!("WS unregistered for {}", fingerprint); tracing::info!("WS unregistered for {}", fingerprint);
} }
/// Try to deliver a message: local push → federation forward → DB queue.
/// Returns true if delivered instantly (local or remote).
pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool {
// 1. Try local WebSocket push
if self.push_to_client(to_fp, message).await {
return true;
}
// 2. Try federation forward
if let Some(ref federation) = self.federation {
if federation.is_remote(to_fp).await {
if federation.forward_message(to_fp, message).await {
return true;
}
}
}
// 3. Queue in local DB
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
let _ = self.db.messages.insert(key.as_bytes(), message);
false
}
/// Check if a fingerprint has any active WS connections.
pub async fn is_online(&self, fingerprint: &str) -> bool {
let conns = self.connections.lock().await;
conns.get(fingerprint).map(|d| !d.is_empty()).unwrap_or(false)
}
/// Count active WS connections for a fingerprint (multi-device).
pub async fn device_count(&self, fingerprint: &str) -> usize {
let conns = self.connections.lock().await;
conns.get(fingerprint).map(|d| d.len()).unwrap_or(0)
}
/// List devices for a fingerprint with metadata.
pub async fn list_devices(&self, fingerprint: &str) -> Vec<(String, i64)> {
let conns = self.connections.lock().await;
conns.get(fingerprint)
.map(|devices| devices.iter().map(|d| (d.device_id.clone(), d.connected_at)).collect())
.unwrap_or_default()
}
/// Kick a specific device by ID. Returns true if found and kicked.
pub async fn kick_device(&self, fingerprint: &str, device_id: &str) -> bool {
let mut conns = self.connections.lock().await;
if let Some(devices) = conns.get_mut(fingerprint) {
let before = devices.len();
devices.retain(|d| d.device_id != device_id);
let kicked = devices.len() < before;
if devices.is_empty() {
conns.remove(fingerprint);
}
kicked
} else {
false
}
}
/// Revoke all connections for a fingerprint except one device_id.
pub async fn revoke_all_except(&self, fingerprint: &str, keep_device_id: &str) -> usize {
let mut conns = self.connections.lock().await;
if let Some(devices) = conns.get_mut(fingerprint) {
let before = devices.len();
devices.retain(|d| d.device_id == keep_device_id);
let removed = before - devices.len();
if devices.is_empty() {
conns.remove(fingerprint);
}
removed
} else {
0
}
}
} }

View File

@@ -474,10 +474,144 @@ pub fn decrypt_wire_message(
"data": hex::encode(&data), "data": hex::encode(&data),
}).to_string()) }).to_string())
} }
_ => { WireMessage::SenderKeyDistribution {
sender_fingerprint,
group_name,
chain_key,
generation,
} => {
// Return the distribution data so JS can store it
Ok(serde_json::json!({ Ok(serde_json::json!({
"type": "unsupported", "type": "sender_key_distribution",
"sender": sender_fingerprint,
"group": group_name,
"chain_key": hex::encode(chain_key),
"generation": generation,
}).to_string())
}
WireMessage::GroupSenderKey {
id,
sender_fingerprint,
group_name,
generation,
counter,
ciphertext,
} => {
// Return the encrypted group message data so JS can decrypt with stored sender key
// JS must call a separate decrypt function with the sender key
Ok(serde_json::json!({
"type": "group_message",
"id": id,
"sender": sender_fingerprint,
"group": group_name,
"generation": generation,
"counter": counter,
"ciphertext": hex::encode(&ciphertext),
}).to_string())
}
WireMessage::CallSignal {
id,
sender_fingerprint,
signal_type,
payload,
target,
} => {
let type_str = match signal_type {
warzone_protocol::message::CallSignalType::Offer => "offer",
warzone_protocol::message::CallSignalType::Answer => "answer",
warzone_protocol::message::CallSignalType::IceCandidate => "ice_candidate",
warzone_protocol::message::CallSignalType::Hangup => "hangup",
warzone_protocol::message::CallSignalType::Reject => "reject",
warzone_protocol::message::CallSignalType::Ringing => "ringing",
warzone_protocol::message::CallSignalType::Busy => "busy",
};
Ok(serde_json::json!({
"type": "call_signal",
"id": id,
"sender": sender_fingerprint,
"signal_type": type_str,
"payload": payload,
"target": target,
}).to_string()) }).to_string())
} }
} }
} }
/// Decrypt a group message using a stored sender key.
///
/// Arguments:
/// - sender_key_hex: hex-encoded bincode-serialized SenderKey (from sender_key_distribution)
/// - sender_fingerprint, group_name, generation, counter, ciphertext_hex: from the group_message JSON
///
/// Returns JSON: { "text": "...", "sender_key": "updated_hex" }
#[wasm_bindgen]
pub fn decrypt_group_message(
sender_key_hex: &str,
sender_fingerprint: &str,
group_name: &str,
generation: u32,
counter: u32,
ciphertext_hex: &str,
) -> Result<String, JsValue> {
use warzone_protocol::sender_keys::{SenderKey, SenderKeyMessage};
let key_bytes = hex::decode(sender_key_hex)
.map_err(|e| JsValue::from_str(&format!("invalid sender key hex: {}", e)))?;
let mut sender_key: SenderKey = bincode::deserialize(&key_bytes)
.map_err(|e| JsValue::from_str(&format!("deserialize sender key: {}", e)))?;
let ciphertext = hex::decode(ciphertext_hex)
.map_err(|e| JsValue::from_str(&format!("invalid ciphertext hex: {}", e)))?;
let msg = SenderKeyMessage {
sender_fingerprint: sender_fingerprint.to_string(),
group_name: group_name.to_string(),
generation,
counter,
ciphertext,
};
let plaintext = sender_key.decrypt(&msg)
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
// Return updated sender key (counter advanced) so JS can persist it
let updated_key = bincode::serialize(&sender_key).unwrap_or_default();
Ok(serde_json::json!({
"text": String::from_utf8_lossy(&plaintext),
"sender_key": hex::encode(updated_key),
}).to_string())
}
/// Create a sender key from a distribution message.
///
/// Takes the fields from a sender_key_distribution JSON and returns
/// a hex-encoded bincode SenderKey that JS should store.
#[wasm_bindgen]
pub fn create_sender_key_from_distribution(
sender_fingerprint: &str,
group_name: &str,
chain_key_hex: &str,
generation: u32,
) -> Result<String, JsValue> {
use warzone_protocol::sender_keys::SenderKeyDistribution;
let chain_key_bytes = hex::decode(chain_key_hex)
.map_err(|e| JsValue::from_str(&format!("invalid chain key hex: {}", e)))?;
let mut chain_key = [0u8; 32];
if chain_key_bytes.len() != 32 {
return Err(JsValue::from_str("chain key must be 32 bytes"));
}
chain_key.copy_from_slice(&chain_key_bytes);
let dist = SenderKeyDistribution {
sender_fingerprint: sender_fingerprint.to_string(),
group_name: group_name.to_string(),
chain_key,
generation,
};
let sender_key = dist.into_sender_key();
let encoded = bincode::serialize(&sender_key).unwrap_or_default();
Ok(hex::encode(encoded))
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
# Warzone Messenger (featherChat) — Progress Report # Warzone Messenger (featherChat) — Progress Report
**Current Version:** 0.0.20 **Current Version:** 0.0.21
**Last Updated:** 2026-03-28 **Last Updated:** 2026-03-28
--- ---
@@ -68,16 +68,42 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
| Reply shortcut (/r, /reply) | 0.0.19 | Done | | Reply shortcut (/r, /reply) | 0.0.19 | Done |
| 28 protocol tests | 0.0.20 | Done | | 28 protocol tests | 0.0.20 | Done |
### Phase 2.5 — WZP Integration & TUI Overhaul (v0.0.21)
| Feature | Version | Status |
|------------------------------------------|---------|--------|
| warzone-protocol standalone-importable | 0.0.21 | Done |
| CallSignal WireMessage variant | 0.0.21 | Done |
| Auth token validation endpoint | 0.0.21 | Done |
| TUI modular split (7 modules from 1) | 0.0.21 | Done |
| TUI message timestamps [HH:MM] | 0.0.21 | Done |
| TUI message scrolling (PageUp/Down/arrows) | 0.0.21 | Done |
| TUI connection status indicator | 0.0.21 | Done |
| TUI unread message badge | 0.0.21 | Done |
| TUI /help command | 0.0.21 | Done |
| TUI terminal bell on incoming DM | 0.0.21 | Done |
| 44 TUI unit tests (types, input, draw) | 0.0.21 | Done |
| Call state management (server) | 0.0.21 | Done |
| WS call signaling awareness | 0.0.21 | Done |
| Group-to-room mapping + group call API | 0.0.21 | Done |
| Presence/online status API | 0.0.21 | Done |
| Missed call notifications | 0.0.21 | Done |
| WZP relay config + CORS | 0.0.21 | Done |
| WZP submodule: all 9 S-tasks done | 0.0.21 | Done |
| 72 total tests (28 protocol + 44 client) | 0.0.21 | Done |
--- ---
## Current Version: v0.0.20 ## Current Version: v0.0.21
### Codebase Statistics ### Codebase Statistics
| Metric | Value | | Metric | Value |
|-------------------|--------------------------------| |-------------------|--------------------------------|
| Crates | 5 (protocol, server, client, wasm, mule) | | Crates | 5 (protocol, server, client, wasm, mule) |
| Protocol tests | 28 | | Total tests | 72 (28 protocol + 44 client) |
| Server routes | 12 files, 9 new endpoints |
| TUI modules | 7 (split from 1 monolith) |
| Rust edition | 2021 | | Rust edition | 2021 |
| Min Rust version | 1.75 | | Min Rust version | 1.75 |
| License | MIT | | License | MIT |
@@ -91,7 +117,7 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
| prekey | Signed + one-time pre-keys | | prekey | Signed + one-time pre-keys |
| x3dh | Extended Triple Diffie-Hellman | | x3dh | Extended Triple Diffie-Hellman |
| ratchet | Double Ratchet state machine | | ratchet | Double Ratchet state machine |
| message | WireMessage (7 variants), content types| | message | WireMessage (8 variants incl. CallSignal)|
| sender_keys | Sender Key encrypt/decrypt/rotate | | sender_keys | Sender Key encrypt/decrypt/rotate |
| history | Encrypted backup format | | history | Encrypted backup format |
| ethereum | secp256k1, Keccak-256, EIP-55 | | ethereum | secp256k1, Keccak-256, EIP-55 |
@@ -121,18 +147,29 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
## Test Suite ## Test Suite
28 tests across the protocol crate: 72 tests across protocol + client crates:
### Protocol Tests (28)
| Module | Tests | Coverage | | Module | Tests | Coverage |
|---------------|-------|---------------------------------------------| |---------------|-------|---------------------------------------------|
| identity | 3 | Deterministic derivation, mnemonic roundtrip, fingerprint format | | identity | 3 | Deterministic derivation, mnemonic roundtrip, fingerprint format |
| crypto | 4 | AEAD roundtrip, wrong key, wrong AAD, HKDF determinism | | crypto | 4 | AEAD roundtrip, wrong key, wrong AAD, HKDF determinism |
| x3dh | ~4 | Initiate/respond, shared secret match, with/without OTPK | | x3dh | 1 | Shared secret match between Alice and Bob |
| ratchet | ~6 | Encrypt/decrypt, out-of-order, multiple messages, ping-pong | | ratchet | 5 | Basic, bidirectional, multiple, out-of-order, 100 messages |
| sender_keys | 4 | Basic encrypt/decrypt, multiple messages, rotation, old key rejection | | sender_keys | 4 | Basic encrypt/decrypt, multiple messages, rotation, old key rejection |
| ethereum | 5 | Deterministic derivation, address format, checksum, sign/verify, different seeds | | ethereum | 5 | Deterministic derivation, address format, checksum, sign/verify, different seeds |
| history | 2 | Roundtrip encryption, wrong seed rejection | | history | 2 | Roundtrip encryption, wrong seed rejection |
| prekey | ~2 | Bundle generation, signature verification | | prekey | 3 | SPK verify, tamper detection, OTPK generation |
| mnemonic | 1 | BIP39 roundtrip |
### Client Tests (44)
| Module | Tests | Coverage |
|---------------|-------|---------------------------------------------|
| tui::types | 10 | App init, scroll/connected defaults, ChatLine timestamps, normfp, add_message |
| tui::input | 25 | 8 text editing, 7 cursor movement, 2 quit, 8 scroll keybindings |
| tui::draw | 9 | Rendering smoke, header fingerprint, connection dot (red/green), timestamps, scroll show/hide, unread badge |
--- ---
@@ -224,11 +261,14 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
- Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM) - Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
- PWA: service worker, offline shell, install prompt - PWA: service worker, offline shell, install prompt
### Priority Order ### Priority Order (Updated v0.0.21)
1. Federation (Phase 3) — enables multi-server deployment 1. **Security (FC-P1)** — auth enforcement, rate limiting, device revocation
2. Mule protocol (Phase 4) — core differentiator for warzone use 2. **TUI call integration (FC-P2)** — /call, /accept, /hangup commands
3. Sealed sender (Phase 6) — strongest metadata privacy 3. **Web call integration (FC-P3)** — WASM CallSignal + browser call UI
4. Push notifications (Phase 7) — usability for mobile/desktop 4. **Protocol hardening (FC-P4)** — session/message versioning
5. Transport fallbacks (Phase 5) — Bluetooth, LoRa 5. Federation (Phase 3) — multi-server deployment
6. Polish (Phase 7) — rate limiting, admin tools, CI 6. Mule protocol (Phase 4) — physical delivery
7. Polish (FC-P6) — search, reactions, typing indicators
See `TASK_PLAN.md` for the detailed task breakdown with IDs and dependencies.

239
warzone/docs/TASK_PLAN.md Normal file
View File

@@ -0,0 +1,239 @@
# featherChat Task Plan
**Version:** 0.0.21+
**Last Updated:** 2026-03-28
**Naming:** `FC-P{phase}-T{task}[-S{subtask}]`
---
## Completed (This Sprint)
### TUI Refactor
- [x] Split `app.rs` monolith (1,756 lines) into 7 modules: types, draw, commands, input, file_transfer, network, mod
- [x] 44 unit tests across types.rs, input.rs, draw.rs
### TUI Improvements
- [x] Message timestamps `[HH:MM]` on every ChatLine
- [x] Message scrolling (PageUp/Down by 10, Up/Down by 1, auto-snap on send)
- [x] Connection status indicator (green/red dot in header)
- [x] Unread badge `[N new]` when scrolled up
- [x] `/help` command listing all commands + navigation
- [x] Terminal bell on incoming DM
### WZP Server Integration (featherChat side)
- [x] FC-2: Call state management (`calls` + `missed_calls` sled trees, `CallState`, `CallStatus`, `active_calls`)
- [x] FC-3: WS call signaling awareness (Offer creates CallState, Answer updates, Hangup ends + missed call on offline)
- [x] FC-5: Group-to-room mapping (`POST /groups/:name/call` with SHA-256 room ID, fan-out to members)
- [x] FC-6: Presence API (`GET /presence/:fp`, `POST /presence/batch`)
- [x] FC-7: Missed call notifications (flush on WS reconnect as `{"type":"missed_call"}`)
- [x] FC-10: WZP relay config (`GET /wzp/relay-config` + CORS layer)
### WZP Side (all 9 tasks done by WZP team)
- [x] WZP-S-1 through WZP-S-9: Identity alignment, relay auth, signaling bridge, room ACL, crypto handshake, web bridge auth, wzp-proto standalone, CLI seed input, hardcoded assumptions fixed
---
## FC-P1: Security & Auth Foundation
**Goal:** Close the security gaps before wider deployment. Auth enforcement is the critical path.
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
| FC-P1-T1 | Auth enforcement middleware | 0.5d | — | TODO |
| FC-P1-T2 | Session auto-recovery | 1d | — | TODO |
| FC-P1-T3 | Rate limiting + connection guards | 0.5d | — | TODO |
| FC-P1-T4 | Device management + session revocation | 1d | T1 | TODO |
### FC-P1-T1: Auth Enforcement Middleware
**What:** Add axum middleware to enforce bearer tokens on protected `/v1/*` routes.
**Why:** Currently anyone can impersonate any fingerprint. Tokens are issued but never required.
**Scope:**
- Extract bearer token from `Authorization` header
- Call `validate_token()` for write operations (send, groups, aliases, calls)
- Read-only routes (health, key fetch) remain unauthenticated
- Return 401 with clear error on invalid/missing token
### FC-P1-T2: Session Auto-Recovery
**What:** When ratchet decryption fails (corrupted state), auto-send a new X3DH KeyExchange.
**Why:** Corrupted session = permanent inability to decrypt from that peer.
**Scope:**
- Detect decryption failure in `process_wire_message()`
- Delete corrupted session from local DB
- Initiate fresh X3DH key exchange
- Show "[session reset]" system message (like Signal)
- Cap auto-recovery attempts (max 3 per peer per hour)
### FC-P1-T3: Rate Limiting + Connection Guards
**What:** Tower rate-limit layer + per-fingerprint connection caps.
**Why:** Zero protection against auth spam, message flooding, WS connection spam.
**Scope:**
- Global rate limit: 100 req/s per IP (tower-governor or tower-http)
- Per-fingerprint WS connection cap: max 5 simultaneous connections
- Auth challenge rate limit: max 10/minute per fingerprint
- Group creation limit: max 5/hour per fingerprint
### FC-P1-T4: Device Management + Session Revocation
**What:** Let users see and kill their active sessions.
**Why:** Compromised or stale devices need to be revocable immediately.
| Subtask | What |
|---------|------|
| FC-P1-T4-S1 | Server: `GET /v1/devices` — list active WS connections (device_id, IP, connected_at) |
| FC-P1-T4-S2 | Server: `POST /v1/devices/:id/kick` — force-close WS + invalidate token |
| FC-P1-T4-S3 | Server: `POST /v1/devices/revoke-all` — nuke all sessions except current |
| FC-P1-T4-S4 | TUI: `/devices` command — list active sessions |
| FC-P1-T4-S5 | TUI: `/kick <device_id>` command — revoke a specific device |
**Dep on T1:** Kick/revoke endpoints must verify the requester owns the fingerprint.
---
## FC-P2: TUI Call Integration
**Goal:** Make call signaling work end-to-end in the TUI. Server infrastructure is ready (FC-2/3/5/6/7).
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
| FC-P2-T1 | `/call <fp>` command — send CallSignal::Offer | 0.5d | — | TODO |
| FC-P2-T2 | `/accept` + `/reject` commands | 0.5d | T1 | TODO |
| FC-P2-T3 | `/hangup` command | 0.25d | T1 | TODO |
| FC-P2-T4 | Call state machine (Idle/Ringing/Active/Ended) | 0.5d | T1 | TODO |
| FC-P2-T4-S1 | Incoming call notification banner | 0.25d | T4 | TODO |
| FC-P2-T4-S2 | In-call header indicator (duration, peer) | 0.25d | T4 | TODO |
| FC-P2-T5 | Missed call display (parse WS JSON) | 0.25d | — | TODO |
| FC-P2-T6 | `/contacts` online status via presence API | 0.25d | — | TODO |
---
## FC-P3: Web Call Integration
**Goal:** Enable voice/video calling from the browser through featherChat's web client.
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
| FC-P3-T1 | WASM: parse CallSignal in `decrypt_wire_message()` | 0.5d | — | TODO |
| FC-P3-T2 | WASM: `create_call_signal()` export for JS | 0.5d | — | TODO |
| FC-P3-T3 | Web client: call/accept/reject UI | 1d | T1, T2 | TODO |
| FC-P3-T4 | Web client: integrate wzp-web audio bridge | 1d | T3 | TODO |
| FC-P3-T5 | Extract web client from monolith (web.rs) | 1-2d | — | TODO |
---
## FC-P4: Protocol & Architecture
**Goal:** Harden the protocol for forward compatibility and resilience.
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
| FC-P4-T1 | Session state versioning | 0.5d | — | TODO |
| FC-P4-T2 | WireMessage versioning (envelope format) | 1d | — | TODO |
| FC-P4-T3 | Periodic auto-backup | 0.5d | — | TODO |
| FC-P4-T4 | libsignal migration assessment | 1-2w | — | TODO |
---
## FC-P5: Major Features
**Goal:** Core differentiators — physical delivery, federation, identity provider.
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
| FC-P5-T1 | Mule binary (physical message delivery) | 3-5d | — | TODO |
| FC-P5-T2 | DNS federation (server discovery + relay) | 2-3w | P4-T2 | TODO |
| FC-P5-T3 | OIDC identity provider | 1-2w | P1-T1 | TODO |
| FC-P5-T4 | Smart contract access control | 3-4w | P5-T3 | TODO |
---
## FC-P6: TUI Polish
**Goal:** UX improvements for daily use.
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
| FC-P6-T1 | Message search (local history) | 1d | — | TODO |
| FC-P6-T2 | Read receipts (viewport tracking) | 0.5d | — | TODO |
| FC-P6-T3 | Typing indicators | 0.5d | — | TODO |
| FC-P6-T4 | Message reactions (emoji) | 1d | P4-T2 | TODO |
| FC-P6-T5 | Voice messages as attachments | 1d | — | TODO |
| FC-P6-T6 | Message wrapping for long text | 0.5d | — | TODO |
| FC-P6-T7 | Tab completion for commands/aliases | 0.5d | — | TODO |
| FC-P6-T8 | File transfer progress gauge | 0.5d | — | TODO |
---
## Parallelization Guide
Tasks with **no dependencies** that can run simultaneously:
**Sprint A (Security — P1):**
```
FC-P1-T1 (auth middleware) — server only
FC-P1-T2 (session recovery) — client only
FC-P1-T3 (rate limiting) — server only
→ then FC-P1-T4 (devices, needs T1)
```
**Sprint B (TUI Calls — P2):**
```
FC-P2-T1 (call command) → T2 (accept/reject) → T3 (hangup)
FC-P2-T4 (state machine) → T4-S1 (banner) + T4-S2 (header)
FC-P2-T5 (missed calls) — independent
FC-P2-T6 (contacts online) — independent
```
**Sprint C (Web — P3):**
```
FC-P3-T1 (WASM parse) — independent
FC-P3-T2 (WASM create) — independent
FC-P3-T5 (extract web.rs) — independent
→ then T3 (call UI) → T4 (audio)
```
---
## Server Architecture (Post-Sprint)
```
warzone-server/src/
├── main.rs — startup, CORS, state init
├── state.rs — AppState, Connections, CallState, DedupTracker
├── db.rs — sled trees: keys, messages, groups, aliases, tokens, calls, missed_calls
├── errors.rs — AppError, AppResult
├── routes/
│ ├── mod.rs — route composition
│ ├── auth.rs — challenge-response, token validation
│ ├── calls.rs NEW — call CRUD, group call, missed calls API
│ ├── presence.rs NEW — online status (single + batch)
│ ├── wzp.rs NEW — relay config + service token
│ ├── groups.rs — group management + fan-out
│ ├── ws.rs — WebSocket handler + call signal awareness + missed call flush
│ ├── keys.rs — pre-key bundle registration
│ ├── messages.rs — HTTP message queue
│ ├── aliases.rs — alias registration + resolution
│ ├── health.rs — health check
│ └── web.rs — embedded web client
```
## TUI Architecture (Post-Sprint)
```
warzone-client/src/tui/
├── mod.rs — run_tui() entry point + event loop
├── types.rs — App, ChatLine, PendingFileTransfer, ReceiptStatus, normfp()
├── draw.rs — UI rendering (timestamps, scroll, connection dot, unread badge)
├── input.rs — keyboard handling (text editing, scroll keys)
├── commands.rs — /slash commands + /help
├── file_transfer.rs — chunked file send (DM + group)
└── network.rs — WS/HTTP polling + incoming message processing + bell
```
## Test Coverage
| Crate | Tests | What |
|-------|------:|------|
| warzone-protocol | 28 | Crypto, ratchet, X3DH, sender keys, identity, ethereum, prekeys |
| warzone-client (types) | 10 | App init, ChatLine, normfp |
| warzone-client (input) | 25 | All keybindings, scroll, text editing |
| warzone-client (draw) | 9 | Rendering, timestamps, scroll, connection dot, unread badge |
| **Total** | **72** | All passing |

View File

@@ -0,0 +1,9 @@
{
"server_id": "alpha",
"shared_secret": "change-me-to-a-long-random-string-shared-between-both-servers",
"peer": {
"id": "bravo",
"url": "http://10.0.0.2:7700"
},
"presence_interval_secs": 5
}