diff --git a/warzone-phone b/warzone-phone index 237adbb..6f4e8eb 160000 --- a/warzone-phone +++ b/warzone-phone @@ -1 +1 @@ -Subproject commit 237adbbf21f41198b397c8169af69f8d328c83ca +Subproject commit 6f4e8eb9f6df1c498cf4c0befbf9f2db5fb4c0d5 diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index a892fe0..0bd3fd2 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -317,6 +317,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -529,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -541,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -680,7 +686,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -706,7 +712,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "serdect", "subtle", @@ -750,7 +756,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -897,6 +903,20 @@ dependencies = [ "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]] name = "getrandom" version = "0.4.2" @@ -905,7 +925,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -917,7 +937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1078,6 +1098,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1433,6 +1454,12 @@ dependencies = [ "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]] name = "matchers" version = "0.2.0" @@ -1624,7 +1651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1716,6 +1743,61 @@ dependencies = [ "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]] name = "quote" version = "1.0.45" @@ -1725,6 +1807,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1738,8 +1826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "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]] @@ -1749,7 +1847,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "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]] @@ -1761,6 +1869,15 @@ dependencies = [ "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]] name = "ratatui" version = "0.28.1" @@ -1841,6 +1958,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -1848,6 +1967,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower 0.5.3", "tower-http 0.6.8", "tower-service", @@ -1855,6 +1975,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -1881,6 +2002,12 @@ dependencies = [ "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]] name = "rustc_version" version = "0.4.1" @@ -1923,6 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1935,6 +2063,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2171,7 +2300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2497,6 +2626,10 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2646,7 +2779,7 @@ dependencies = [ "httparse", "log", "native-tls", - "rand", + "rand 0.8.5", "sha1", "thiserror 1.0.69", "utf-8", @@ -2802,7 +2935,7 @@ dependencies = [ "futures-util", "hex", "libc", - "rand", + "rand 0.8.5", "ratatui", "reqwest", "serde", @@ -2843,7 +2976,7 @@ dependencies = [ "hex", "hkdf", "k256", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -2867,9 +3000,11 @@ dependencies = [ "ed25519-dalek", "futures-util", "hex", - "rand", + "rand 0.8.5", + "reqwest", "serde", "serde_json", + "sha2", "sled", "thiserror 2.0.18", "tokio", @@ -2891,7 +3026,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "js-sys", - "rand", + "rand 0.8.5", "serde", "serde_json", "uuid", @@ -3028,6 +3163,25 @@ dependencies = [ "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]] name = "winapi" version = "0.3.9" @@ -3312,7 +3466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 06442ad..2d1fa5c 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -42,7 +42,7 @@ tokio = { version = "1", features = ["full"] } # Server axum = { version = "0.7", features = ["ws"] } -tower = "0.4" +tower = { version = "0.4", features = ["limit"] } tower-http = { version = "0.5", features = ["cors", "trace"] } # Client HTTP diff --git a/warzone/README.md b/warzone/README.md new file mode 100644 index 0000000..6adc2ee --- /dev/null +++ b/warzone/README.md @@ -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 ` or `/p @alias` | Set DM peer | +| `/g ` | Switch to group (auto-join) | +| `/call ` | Initiate call | +| `/file ` | Send file (max 10MB) | +| `/contacts` | List contacts with message counts | +| `/history` | Show conversation history | +| `/devices` | List active device sessions | +| `/kick ` | 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 diff --git a/warzone/crates/warzone-client/src/storage.rs b/warzone/crates/warzone-client/src/storage.rs index 5c992e5..4691006 100644 --- a/warzone/crates/warzone-client/src/storage.rs +++ b/warzone/crates/warzone-client/src/storage.rs @@ -10,6 +10,7 @@ pub struct LocalDb { pre_keys: sled::Tree, contacts: sled::Tree, history: sled::Tree, + sender_keys: sled::Tree, _db: sled::Db, } @@ -39,11 +40,13 @@ impl LocalDb { let pre_keys = db.open_tree("pre_keys")?; let contacts = db.open_tree("contacts")?; let history = db.open_tree("history")?; + let sender_keys = db.open_tree("sender_keys")?; Ok(LocalDb { sessions, pre_keys, contacts, history, + sender_keys, _db: db, }) } @@ -57,6 +60,14 @@ impl LocalDb { 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. pub fn load_session(&self, peer: &Fingerprint) -> Result> { 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> { + 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 ── /// Add or update a contact. Called on send/receive. diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs deleted file mode 100644 index 357875e..0000000 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ /dev/null @@ -1,1756 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyModifiers}; -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 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; - -/// Maximum file size: 10 MB. -const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; -/// Chunk size: 64 KB. -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>>, - 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>>, - pub our_fp: String, - pub peer_fp: Option, - pub server_url: String, - pub should_quit: bool, - pub cursor_pos: usize, - pub last_dm_peer: Arc>>, - /// Track receipt status for messages we sent, keyed by message ID. - pub receipts: Arc>>, - /// Pending incoming file transfers, keyed by file ID. - pub pending_files: Arc>>, -} - -#[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, -} - -impl App { - pub fn new(our_fp: String, peer_fp: Option, 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, - }])); - - 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, - }); - } else { - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: "No peer set. Use /peer , /peer @alias, or /g ".into(), - is_system: true, - is_self: false, - message_id: None, - }); - } - - 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, - }); - - 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())), - } - } - - pub fn add_message(&self, line: ChatLine) { - self.messages.lock().unwrap().push(line); - } - - fn receipt_indicator(&self, message_id: &Option) -> &'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) -> 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 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(" → "), - Span::styled(peer_str, Style::default().fg(Color::Yellow)), - Span::styled( - format!(" [{}]", self.server_url), - Style::default().fg(Color::DarkGray), - ), - ])); - frame.render_widget(header, chunks[0]); - - // Messages - let msgs = self.messages.lock().unwrap(); - let items: Vec = 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 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(prefix, style.add_modifier(Modifier::BOLD)), - Span::raw(&m.text), - Span::styled(receipt_str, Style::default().fg(receipt_color)), - ])) - }) - .collect(); - - let messages_widget = List::new(items) - .block(Block::default().borders(Borders::TOP)); - frame.render_widget(messages_widget, chunks[1]); - - // Input - let input_widget = Paragraph::new(self.input.as_str()) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) - .title(" message "), - ) - .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)); - } - - 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, - }); - 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::().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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None }); - } - }, - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - 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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None }); - 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 }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - 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 (or set peer first)".into(), is_system: true, is_self: false, message_id: None }); - } 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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None }); - 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, - }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - 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 }); - } - 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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None }); - } - 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, - }); - 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, - }); - self.peer_fp = Some(format!("#{}", name)); - return; - } - if text == "/dm" { - self.add_message(ChatLine { - sender: "system".into(), - text: "Switched to DM mode. Use /peer ".into(), - is_system: true, - is_self: false, - message_id: None, - }); - 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 first".into(), is_system: true, is_self: false, message_id: None }); - } - } - 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 }); - } - } - 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 }); - } - } - 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 ".into(), - is_system: true, - is_self: false, - message_id: None, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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), - }); - } - Err(e) => { - self.add_message(ChatLine { - sender: "system".into(), - text: format!("Send failed: {}", e), - is_system: true, - is_self: false, - message_id: None, - }); - } - } - } - - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - - // 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::().await { - Ok(d) => d, - Err(_) => return, - }, - Err(_) => return, - }; - let my_fp = normfp(&self.our_fp); - let members: Vec = 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, - }); - 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, - }); - 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, - }); - - // 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - 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, - }); - } - - 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, - }); - } - - 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::().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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - 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::().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 }); - } 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 }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - 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::().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 }); - } 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 }); - } - } - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - 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::().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 }); - } else { - self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - 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::().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 }); - } 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 }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - 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::().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 }); - 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 }); - } - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - 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::().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 }); return; } - }, - Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }); return; } - }; - - let my_fp = normfp(&self.our_fp); - let members: Vec = 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 = 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 }); - 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, - }); - } - Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None }); - } - } - } - - 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::().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 }); - } 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 }); - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } - - async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option { - 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::().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 }); - 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 }); - } - } - None - } - Err(e) => { - self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }); - 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::().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 }); - } 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 }); - } - } - } - } - } - Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None }), - } - } -} - -fn normfp(fp: &str) -> String { - fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() -} - -/// 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; - }); -} - -/// Process a single incoming raw message (shared by WS and HTTP paths). -fn process_incoming( - raw: &[u8], - identity: &IdentityKeyPair, - db: &LocalDb, - messages: &Arc>>, - receipts: &Arc>>, - pending_files: &Arc>>, - our_fp: &str, - client: &ServerClient, - last_dm_peer: &Arc>>, -) { - match bincode::deserialize::(raw) { - Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer), - Err(_) => {} - } -} - -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); -} - -fn process_wire_message( - wire: WireMessage, - identity: &IdentityKeyPair, - db: &LocalDb, - messages: &Arc>>, - receipts: &Arc>>, - pending_files: &Arc>>, - our_fp: &str, - client: &ServerClient, - last_dm_peer: &Arc>>, -) { - 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, - }); - send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); - } - Err(_) => {} - } - } - 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, - }); - send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client); - } - Err(_) => {} - } - } - 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, - }); - - 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, - }); - - // 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, - }); - } 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, - }); - } - 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, - }); - } - } - } - - // Remove completed transfer - pf.remove(&id); - } - } - } else { - // Received chunk without header — ignore - } - } - WireMessage::GroupSenderKey { - id: _, - sender_fingerprint, - group_name, - generation: _, - counter: _, - ciphertext: _, - } => { - // TODO: decrypt with stored sender key for this sender+group - messages.lock().unwrap().push(ChatLine { - sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), - text: format!("[group #{} sender-key message — key setup needed]", group_name), - is_system: false, - is_self: false, - message_id: None, - }); - } - WireMessage::SenderKeyDistribution { - sender_fingerprint, - group_name, - chain_key: _, - generation: _, - } => { - // TODO: store this sender key for future group decryption - 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, - }); - } - 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!("📞 Call signal: {}", type_str), - is_system: false, - is_self: false, - message_id: None, - }); - } - } -} - -/// Real-time message loop via WebSocket (falls back to HTTP polling). -pub async fn poll_loop( - messages: Arc>>, - receipts: Arc>>, - pending_files: Arc>>, - our_fp: String, - identity: IdentityKeyPair, - db: Arc, - client: ServerClient, - last_dm_peer: Arc>>, -) { - 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, _)) => { - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: "Real-time connection established".into(), - is_system: true, - is_self: false, - message_id: None, - }); - - 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); - } - } - - messages.lock().unwrap().push(ChatLine { - sender: "system".into(), - text: "Connection lost, reconnecting...".into(), - is_system: true, - is_self: false, - message_id: None, - }); - tokio::time::sleep(Duration::from_secs(3)).await; - } - Err(_) => { - // 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); - } - } - } - } -} - -/// Run the TUI event loop. -pub async fn run_tui( - our_fp: String, - peer_fp: Option, - server_url: String, - identity: IdentityKeyPair, - poll_seed: warzone_protocol::identity::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_client = client.clone(); - let poll_db = db.clone(); - let poll_fp = our_fp.clone(); - - tokio::spawn(async move { - poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm).await; - }); - - loop { - terminal.draw(|frame| app.draw(frame))?; - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Enter => { - app.handle_send(&identity, &db, &client).await; - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.should_quit = true; - } - // Alt+Backspace / Ctrl+W: delete word before cursor - KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { - if app.cursor_pos > 0 { - let before = &app.input[..app.cursor_pos]; - let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0); - app.input.drain(new_pos..app.cursor_pos); - app.cursor_pos = new_pos; - } - } - // Backspace: delete char before cursor - KeyCode::Backspace => { - if app.cursor_pos > 0 { - app.input.remove(app.cursor_pos - 1); - app.cursor_pos -= 1; - } - } - // Delete: delete char at cursor - KeyCode::Delete => { - if app.cursor_pos < app.input.len() { - app.input.remove(app.cursor_pos); - } - } - // Left arrow - KeyCode::Left => { - if key.modifiers.contains(KeyModifiers::ALT) { - // Alt+Left: word left - let before = &app.input[..app.cursor_pos]; - app.cursor_pos = before.rfind(' ').map(|i| i).unwrap_or(0); - } else if app.cursor_pos > 0 { - app.cursor_pos -= 1; - } - } - // Right arrow - KeyCode::Right => { - if key.modifiers.contains(KeyModifiers::ALT) { - // Alt+Right: word right - let after = &app.input[app.cursor_pos..]; - app.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len()); - } else if app.cursor_pos < app.input.len() { - app.cursor_pos += 1; - } - } - // Home / Ctrl+A - KeyCode::Home => { app.cursor_pos = 0; } - KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.cursor_pos = 0; - } - // End / Ctrl+E - KeyCode::End => { app.cursor_pos = app.input.len(); } - KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.cursor_pos = app.input.len(); - } - // Ctrl+U: clear line - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.input.clear(); - app.cursor_pos = 0; - } - // Ctrl+K: kill to end of line - KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { - app.input.truncate(app.cursor_pos); - } - // Ctrl+W: delete word back - KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let before = &app.input[..app.cursor_pos]; - let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0); - app.input.drain(new_pos..app.cursor_pos); - app.cursor_pos = new_pos; - } - // Regular char: insert at cursor - KeyCode::Char(c) => { - app.input.insert(app.cursor_pos, c); - app.cursor_pos += 1; - } - KeyCode::Esc => { - app.should_quit = true; - } - _ => {} - } - } - } - - if app.should_quit { - break; - } - } - - ratatui::restore(); - Ok(()) -} diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs new file mode 100644 index 0000000..7298c6f --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -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 , /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 Register an alias for yourself", + " /aliases List all registered aliases", + " /unalias Remove your alias", + " /devices List your active device sessions", + " /kick Kick a specific device session", + " /g Switch to group (auto-join)", + " /gcreate Create a new group", + " /gjoin Join a group", + " /glist List all groups", + " /gleave Leave current group", + " /gkick Kick member from group", + " /gmembers List group members", + " /file 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::().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 (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::().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 ".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::().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 ".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 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 ".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::().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::().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::().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::().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::().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::().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::().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 = 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 = 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::().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 { + 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::().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::().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() }), + } + } +} diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs new file mode 100644 index 0000000..685659c --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -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) -> &'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) -> 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 = 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) -> 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, needle: &str) -> bool { + full_buffer_text(terminal).contains(needle) + } + + /// Helper: collect a single row into a String. + fn row_text(terminal: &Terminal, 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 { + 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" + ); + } +} diff --git a/warzone/crates/warzone-client/src/tui/file_transfer.rs b/warzone/crates/warzone-client/src/tui/file_transfer.rs new file mode 100644 index 0000000..4c53f11 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/file_transfer.rs @@ -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::().await { + Ok(d) => d, + Err(_) => return, + }, + Err(_) => return, + }; + let my_fp = normfp(&self.our_fp); + let members: Vec = 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(), + }); + } +} diff --git a/warzone/crates/warzone-client/src/tui/input.rs b/warzone/crates/warzone-client/src/tui/input.rs new file mode 100644 index 0000000..60bde52 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/input.rs @@ -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); + } +} diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index de3e58d..7ec3326 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -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, + 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(()) +} diff --git a/warzone/crates/warzone-client/src/tui/network.rs b/warzone/crates/warzone-client/src/tui/network.rs new file mode 100644 index 0000000..917138b --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/network.rs @@ -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>>, + receipts: &Arc>>, + pending_files: &Arc>>, + our_fp: &str, + client: &ServerClient, + last_dm_peer: &Arc>>, +) { + match bincode::deserialize::(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>>, + receipts: &Arc>>, + pending_files: &Arc>>, + our_fp: &str, + client: &ServerClient, + last_dm_peer: &Arc>>, +) { + 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>>, + receipts: Arc>>, + pending_files: Arc>>, + our_fp: String, + identity: IdentityKeyPair, + db: Arc, + client: ServerClient, + last_dm_peer: Arc>>, + connected: Arc, +) { + 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); + } + } + } + } +} diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs new file mode 100644 index 0000000..17bb889 --- /dev/null +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -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>>, + 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>>, + pub our_fp: String, + pub peer_fp: Option, + pub server_url: String, + pub should_quit: bool, + pub cursor_pos: usize, + pub last_dm_peer: Arc>>, + /// Track receipt status for messages we sent, keyed by message ID. + pub receipts: Arc>>, + /// Pending incoming file transfers, keyed by file ID. + pub pending_files: Arc>>, + /// Scroll offset from bottom (0 = pinned to newest). + pub scroll_offset: usize, + /// Whether the WebSocket connection is active. + pub connected: Arc, +} + +#[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, + /// When this message was created/received. + pub timestamp: DateTime, +} + +impl App { + pub fn new(our_fp: String, peer_fp: Option, 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 , /peer @alias, or /g ".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::().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); + } +} diff --git a/warzone/crates/warzone-server/Cargo.toml b/warzone/crates/warzone-server/Cargo.toml index d1e6dd1..8753ffe 100644 --- a/warzone/crates/warzone-server/Cargo.toml +++ b/warzone/crates/warzone-server/Cargo.toml @@ -25,3 +25,5 @@ rand.workspace = true futures-util = "0.3" ed25519-dalek.workspace = true bincode.workspace = true +sha2.workspace = true +reqwest = { workspace = true, features = ["rustls-tls", "json"] } diff --git a/warzone/crates/warzone-server/src/auth_middleware.rs b/warzone/crates/warzone-server/src/auth_middleware.rs new file mode 100644 index 0000000..7f31e72 --- /dev/null +++ b/warzone/crates/warzone-server/src/auth_middleware.rs @@ -0,0 +1,84 @@ +//! Auth enforcement middleware: axum extractor that validates bearer tokens. +//! +//! Reads `Authorization: Bearer ` 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, +/// ) -> impl IntoResponse { +/// let fp = auth.fingerprint; // guaranteed valid +/// // ... +/// } +/// ``` +pub struct AuthFingerprint { + pub fingerprint: String, +} + +#[axum::async_trait] +impl FromRequestParts for AuthFingerprint { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + 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 ` 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 header", + ), + AuthError::InvalidToken => ( + StatusCode::UNAUTHORIZED, + "invalid or expired token", + ), + }; + (status, axum::Json(serde_json::json!({ "error": msg }))).into_response() + } +} diff --git a/warzone/crates/warzone-server/src/db.rs b/warzone/crates/warzone-server/src/db.rs index 369eb26..5cbf538 100644 --- a/warzone/crates/warzone-server/src/db.rs +++ b/warzone/crates/warzone-server/src/db.rs @@ -6,6 +6,8 @@ pub struct Database { pub groups: sled::Tree, pub aliases: sled::Tree, pub tokens: sled::Tree, + pub calls: sled::Tree, + pub missed_calls: sled::Tree, _db: sled::Db, } @@ -17,12 +19,16 @@ impl Database { let groups = db.open_tree("groups")?; let aliases = db.open_tree("aliases")?; let tokens = db.open_tree("tokens")?; + let calls = db.open_tree("calls")?; + let missed_calls = db.open_tree("missed_calls")?; Ok(Database { keys, messages, groups, aliases, tokens, + calls, + missed_calls, _db: db, }) } diff --git a/warzone/crates/warzone-server/src/federation.rs b/warzone/crates/warzone-server/src/federation.rs new file mode 100644 index 0000000..3c5500a --- /dev/null +++ b/warzone/crates/warzone-server/src/federation.rs @@ -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 { + 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, + 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>, +} + +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) -> 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 = { + 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) +} diff --git a/warzone/crates/warzone-server/src/lib.rs b/warzone/crates/warzone-server/src/lib.rs index 1c0b582..e852ef1 100644 --- a/warzone/crates/warzone-server/src/lib.rs +++ b/warzone/crates/warzone-server/src/lib.rs @@ -1,5 +1,7 @@ +pub mod auth_middleware; pub mod config; pub mod db; pub mod errors; +pub mod federation; pub mod routes; pub mod state; diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 3c98027..93d2c9c 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -1,8 +1,10 @@ use clap::Parser; +pub mod auth_middleware; mod config; mod db; mod errors; +mod federation; mod routes; mod state; @@ -16,6 +18,10 @@ struct Cli { /// Database directory #[arg(short, long, default_value = "./warzone-data")] data_dir: String, + + /// Federation config file (JSON). Enables server-to-server message relay. + #[arg(short, long)] + federation: Option, } #[tokio::main] @@ -30,11 +36,38 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); 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() .merge(routes::web_router()) .nest("/v1", routes::router()) + .layer(cors) + .layer(tower::limit::ConcurrencyLimitLayer::new(200)) .layer(tower_http::trace::TraceLayer::new_for_http()) .with_state(state); diff --git a/warzone/crates/warzone-server/src/routes/aliases.rs b/warzone/crates/warzone-server/src/routes/aliases.rs index 884c2a9..c6faad2 100644 --- a/warzone/crates/warzone-server/src/routes/aliases.rs +++ b/warzone/crates/warzone-server/src/routes/aliases.rs @@ -112,6 +112,7 @@ struct RegisterRequest { /// - Expired aliases (past grace period) can be reclaimed by anyone /// - Expired aliases (within grace period) can only be reclaimed by recovery key async fn register_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -190,6 +191,7 @@ struct RecoverRequest { /// Recover an alias using the recovery key. Works even if expired (within or past grace). async fn recover_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -244,6 +246,7 @@ struct RenewRequest { /// Renew/heartbeat — resets the TTL. Called automatically on activity. async fn renew_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -347,6 +350,7 @@ struct UnregisterRequest { /// Remove your own alias. async fn unregister_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -381,6 +385,7 @@ struct AdminRemoveRequest { /// Admin: remove any alias. async fn admin_remove_alias( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { diff --git a/warzone/crates/warzone-server/src/routes/calls.rs b/warzone/crates/warzone-server/src/routes/calls.rs new file mode 100644 index 0000000..e12f5e8 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/calls.rs @@ -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 { + 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::().to_lowercase() +} + +#[derive(Deserialize)] +struct InitiateRequest { + caller: String, + callee: String, +} + +async fn initiate_call( + _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, + Json(req): Json, +) -> AppResult> { + 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, + Path(id): Path, +) -> AppResult> { + // 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, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + 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, +} + +async fn active_calls( + State(state): State, + Query(q): Query, +) -> AppResult> { + 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, + Json(req): Json, +) -> AppResult> { + 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::(&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, + Path(name): Path, + Json(req): Json, +) -> AppResult> { + 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 = 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, + }))) +} diff --git a/warzone/crates/warzone-server/src/routes/devices.rs b/warzone/crates/warzone-server/src/routes/devices.rs new file mode 100644 index 0000000..d143aa0 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/devices.rs @@ -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 { + 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, +) -> AppResult> { + let devices = state.list_devices(&auth.fingerprint).await; + let list: Vec = 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, + axum::extract::Path(device_id): axum::extract::Path, +) -> AppResult> { + 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, + Json(req): Json, +) -> AppResult> { + 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::(&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, + }))) +} diff --git a/warzone/crates/warzone-server/src/routes/federation.rs b/warzone/crates/warzone-server/src/routes/federation.rs new file mode 100644 index 0000000..3d2d718 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/federation.rs @@ -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 { + 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, + 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 = 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, + 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, +) -> Json { + 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, + })) + } + } +} diff --git a/warzone/crates/warzone-server/src/routes/groups.rs b/warzone/crates/warzone-server/src/routes/groups.rs index 73ceef5..f866013 100644 --- a/warzone/crates/warzone-server/src/routes/groups.rs +++ b/warzone/crates/warzone-server/src/routes/groups.rs @@ -75,6 +75,7 @@ fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> { } async fn create_group( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -99,6 +100,7 @@ async fn create_group( } async fn join_group( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Path(name): Path, Json(req): Json, @@ -169,6 +171,7 @@ async fn list_groups( /// queue infrastructure — group messages look like 1:1 messages to the /// recipient, but with a group tag. async fn send_to_group( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Path(name): Path, Json(req): Json, @@ -210,6 +213,7 @@ async fn send_to_group( } async fn leave_group( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Path(name): Path, Json(req): Json, @@ -235,6 +239,7 @@ struct KickRequest { } async fn kick_member( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Path(name): Path, Json(req): Json, diff --git a/warzone/crates/warzone-server/src/routes/keys.rs b/warzone/crates/warzone-server/src/routes/keys.rs index 87b5ebb..b65cd03 100644 --- a/warzone/crates/warzone-server/src/routes/keys.rs +++ b/warzone/crates/warzone-server/src/routes/keys.rs @@ -54,6 +54,7 @@ struct RegisterResponse { } async fn register_keys( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> Json { @@ -129,6 +130,7 @@ struct OtpkEntry { /// Upload additional one-time pre-keys. async fn replenish_otpks( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> Json { diff --git a/warzone/crates/warzone-server/src/routes/messages.rs b/warzone/crates/warzone-server/src/routes/messages.rs index f9b6d57..aa44d48 100644 --- a/warzone/crates/warzone-server/src/routes/messages.rs +++ b/warzone/crates/warzone-server/src/routes/messages.rs @@ -71,6 +71,7 @@ fn normalize_fp(fp: &str) -> String { } async fn send_message( + _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { @@ -84,14 +85,11 @@ async fn send_message( } } - // Try WebSocket push first (instant delivery) - if state.push_to_client(&to, &req.message).await { - tracing::info!("Pushed message to {} via WS ({} bytes)", to, req.message.len()); + let delivered = state.deliver_or_queue(&to, &req.message).await; + if delivered { + tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len()); } else { - // Queue in DB (offline delivery) - 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)?; + tracing::info!("Queued message for {} ({} bytes)", to, req.message.len()); } // Renew sender's alias TTL (sending = authenticated action) diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index 3a8ddbf..f81b01b 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,11 +1,16 @@ mod aliases; pub mod auth; +mod calls; +mod devices; +mod federation; mod groups; mod health; mod keys; pub mod messages; +mod presence; mod web; mod ws; +mod wzp; use axum::Router; @@ -20,6 +25,11 @@ pub fn router() -> Router { .merge(aliases::routes()) .merge(auth::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) diff --git a/warzone/crates/warzone-server/src/routes/presence.rs b/warzone/crates/warzone-server/src/routes/presence.rs new file mode 100644 index 0000000..4ac717d --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/presence.rs @@ -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 { + 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::().to_lowercase() +} + +async fn get_presence( + State(state): State, + Path(fingerprint): Path, +) -> AppResult> { + 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, +} + +async fn batch_presence( + _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, + Json(req): Json, +) -> AppResult> { + 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 }))) +} diff --git a/warzone/crates/warzone-server/src/routes/ws.rs b/warzone/crates/warzone-server/src/routes/ws.rs index f8d19f3..fc73219 100644 --- a/warzone/crates/warzone-server/src/routes/ws.rs +++ b/warzone/crates/warzone-server/src/routes/ws.rs @@ -66,16 +66,20 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String) let (mut ws_tx, mut ws_rx) = socket.split(); // 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 let prefix = format!("queue:{}", fingerprint); let mut keys_to_delete = Vec::new(); - for item in state.db.messages.scan_prefix(prefix.as_bytes()) { - if let Ok((key, value)) = item { - if ws_tx.send(Message::Binary(value.to_vec().into())).await.is_ok() { - keys_to_delete.push(key); - } + for (key, value) in state.db.messages.scan_prefix(prefix.as_bytes()).flatten() { + if ws_tx.send(Message::Binary(value.to_vec())).await.is_ok() { + keys_to_delete.push(key); } } 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()); } + // 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::(&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 let _fp_clone = fingerprint.clone(); let mut push_task = tokio::spawn(async move { 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; } } @@ -119,13 +146,77 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String) } } - // Try push to connected client first - if !state_clone.push_to_client(&to_fp, message).await { - // Queue in DB - let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); - let _ = state_clone.db.messages.insert(key.as_bytes(), message); + // Call signal side effects + if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = bincode::deserialize::(message) { + use warzone_protocol::message::CallSignalType; + let now = chrono::Utc::now().timestamp(); + 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); } } @@ -147,10 +238,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String) } } - if !state_clone.push_to_client(&to_fp, &message).await { - let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); - let _ = state_clone.db.messages.insert(key.as_bytes(), message); - } + // Deliver via local WS, federation, or queue in DB + state_clone.deliver_or_queue(&to_fp, &message).await; // 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 // In production, use a unique connection ID let mut conns = state.connections.lock().await; - if let Some(senders) = conns.get_mut(&fingerprint) { - senders.retain(|s| !s.is_closed()); - if senders.is_empty() { + if let Some(devices) = conns.get_mut(&fingerprint) { + devices.retain(|d| !d.sender.is_closed()); + if devices.is_empty() { conns.remove(&fingerprint); } } diff --git a/warzone/crates/warzone-server/src/routes/wzp.rs b/warzone/crates/warzone-server/src/routes/wzp.rs new file mode 100644 index 0000000..aad896b --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/wzp.rs @@ -0,0 +1,45 @@ +use axum::{ + extract::State, + routing::get, + Json, Router, +}; + +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + 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, +) -> AppResult> { + // 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, + }))) +} diff --git a/warzone/crates/warzone-server/src/state.rs b/warzone/crates/warzone-server/src/state.rs index 899cd1d..287fb70 100644 --- a/warzone/crates/warzone-server/src/state.rs +++ b/warzone/crates/warzone-server/src/state.rs @@ -4,14 +4,26 @@ use tokio::sync::{Mutex, mpsc}; 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. const DEDUP_CAPACITY: usize = 10_000; /// Per-connection sender: messages are pushed here for instant delivery. pub type WsSender = mpsc::UnboundedSender>; -/// Connected clients: fingerprint → list of WS senders (multiple devices). -pub type Connections = Arc>>>; +/// Metadata for a single connected device. +#[derive(Clone)] +pub struct DeviceConnection { + pub device_id: String, + pub sender: WsSender, + pub connected_at: i64, + pub token: Option, +} + +/// Connected clients: fingerprint → list of device connections (multiple devices). +pub type Connections = Arc>>>; /// Bounded dedup tracker: FIFO eviction when capacity is exceeded. #[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, + pub room_id: Option, + pub status: CallStatus, + pub created_at: i64, + pub answered_at: Option, + pub ended_at: Option, +} + #[derive(Clone)] pub struct AppState { pub db: Arc, pub connections: Connections, pub dedup: DedupTracker, + pub active_calls: Arc>>, + pub federation: Option, } impl AppState { @@ -61,16 +97,18 @@ impl AppState { db: Arc::new(db), connections: Arc::new(Mutex::new(HashMap::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. pub async fn push_to_client(&self, fingerprint: &str, message: &[u8]) -> bool { let conns = self.connections.lock().await; - if let Some(senders) = conns.get(fingerprint) { + if let Some(devices) = conns.get(fingerprint) { let mut delivered = false; - for sender in senders { - if sender.send(message.to_vec()).is_ok() { + for device in devices { + if device.sender.send(message.to_vec()).is_ok() { delivered = true; } } @@ -81,25 +119,127 @@ impl AppState { } /// Register a WS connection for a fingerprint. - pub async fn register_ws(&self, fingerprint: &str) -> mpsc::UnboundedReceiver> { + /// + /// 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) -> Option<(String, mpsc::UnboundedReceiver>)> { 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; - conns.entry(fingerprint.to_string()).or_default().push(tx); - tracing::info!("WS registered for {} ({} total connections)", fingerprint, - conns.values().map(|v| v.len()).sum::()); - rx + let entry = conns.entry(fingerprint.to_string()).or_default(); + + // Clean up closed connections first + 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::() + ); + Some((device_id, rx)) } /// Unregister a WS connection. #[allow(dead_code)] pub async fn unregister_ws(&self, fingerprint: &str, sender: &WsSender) { let mut conns = self.connections.lock().await; - if let Some(senders) = conns.get_mut(fingerprint) { - senders.retain(|s| !s.same_channel(sender)); - if senders.is_empty() { + if let Some(devices) = conns.get_mut(fingerprint) { + devices.retain(|d| !d.sender.same_channel(sender)); + if devices.is_empty() { conns.remove(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 + } + } } diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs index 0ee4f58..b655f18 100644 --- a/warzone/crates/warzone-wasm/src/lib.rs +++ b/warzone/crates/warzone-wasm/src/lib.rs @@ -474,10 +474,144 @@ pub fn decrypt_wire_message( "data": hex::encode(&data), }).to_string()) } - _ => { + WireMessage::SenderKeyDistribution { + sender_fingerprint, + group_name, + chain_key, + generation, + } => { + // Return the distribution data so JS can store it 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()) } } } + +/// 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 { + 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 { + 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)) +} diff --git a/warzone/docs/ARCHITECTURE.md b/warzone/docs/ARCHITECTURE.md index 11af76e..8ff2522 100644 --- a/warzone/docs/ARCHITECTURE.md +++ b/warzone/docs/ARCHITECTURE.md @@ -1,134 +1,186 @@ # Warzone Messenger (featherChat) — Architecture -**Version:** 0.0.20 -**Status:** Phase 1 complete, Phase 2 complete +**Version:** 0.0.21 +**Status:** Phase 1 + Phase 2 + WZP Integration + Federation --- ## High-Level Architecture -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Clients │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ -│ │ CLI Client │ │ TUI Client │ │ Web Client (WASM) │ │ -│ │ (warzone) │ │ (ratatui) │ │ (wasm-bindgen) │ │ -│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │ -│ │ │ │ │ -│ ┌──────┴─────────────────┴───────────────────────┴──────────┐ │ -│ │ warzone-protocol │ │ -│ │ Identity · X3DH · Double Ratchet · Sender Keys · History │ │ -│ └───────────────────────────┬───────────────────────────────┘ │ -└──────────────────────────────┼──────────────────────────────────┘ - │ HTTP / WebSocket - ▼ -┌──────────────────────────────────────────────────────────────────┐ -│ warzone-server │ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │ -│ │ HTTP API │ │ WebSocket│ │ Auth │ │ Message Router │ │ -│ │ (axum) │ │ Relay │ │Challenge │ │ + Dedup │ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────────┬────────┘ │ -│ │ │ │ │ │ -│ ┌────┴─────────────┴─────────────┴──────────────────┴────────┐ │ -│ │ sled Database │ │ -│ │ keys · messages · groups · aliases · tokens │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────┘ +```mermaid +graph TB + subgraph Clients + CLI["CLI Client
(warzone)"] + TUI["TUI Client
(ratatui)"] + WEB["Web Client
(WASM)"] + end + + subgraph Protocol["warzone-protocol (shared library)"] + ID["Identity
Ed25519 + X25519"] + X3DH["X3DH
Key Agreement"] + DR["Double Ratchet
Forward Secrecy"] + SK["Sender Keys
Group Encryption"] + WIRE["WireMessage
8 variants"] + end + + subgraph ServerA["warzone-server (Alpha)"] + API_A["REST API
(axum)"] + WS_A["WebSocket
Relay"] + AUTH_A["Auth
Middleware"] + CALLS_A["Call State
Manager"] + FED_A["Federation
Module"] + DB_A["sled DB
7 trees"] + end + + subgraph ServerB["warzone-server (Bravo)"] + API_B["REST API"] + WS_B["WebSocket Relay"] + FED_B["Federation Module"] + DB_B["sled DB"] + end + + subgraph WZP["WarzonePhone"] + RELAY["WZP Relay
(QUIC SFU)"] + BRIDGE["Web Bridge
(audio)"] + end + + CLI --> Protocol + TUI --> Protocol + WEB --> Protocol + Protocol -->|"HTTP / WS"| ServerA + Protocol -->|"HTTP / WS"| ServerB + + FED_A <-->|"HTTP REST
HMAC-SHA256"| FED_B + + ServerA -->|"Call Signaling
Token Validation"| WZP + ServerB -->|"Call Signaling"| WZP ``` --- ## Crate Structure -The project is a Cargo workspace with five crates: +```mermaid +graph LR + subgraph Workspace + PROTO["warzone-protocol
(library, no I/O)"] + SERVER["warzone-server
(axum binary)"] + CLIENT["warzone-client
(CLI/TUI binary)"] + WASM["warzone-wasm
(wasm-bindgen)"] + MULE["warzone-mule
(future)"] + end + + SERVER --> PROTO + CLIENT --> PROTO + WASM --> PROTO + MULE --> PROTO + + subgraph External["WarzonePhone (submodule)"] + WZP_PROTO["wzp-proto"] + WZP_CRYPTO["wzp-crypto"] + WZP_RELAY["wzp-relay"] + WZP_WEB["wzp-web"] + end +``` ``` warzone/ -├── Cargo.toml # Workspace root (v0.0.20) +├── Cargo.toml # Workspace root (v0.0.21) +├── federation.example.json # Federation config template ├── crates/ -│ ├── warzone-protocol/ # Core crypto & message types (library) +│ ├── warzone-protocol/ # Core crypto & message types │ ├── warzone-server/ # Server binary (axum + sled) │ ├── warzone-client/ # CLI/TUI client binary │ ├── warzone-wasm/ # WASM bridge for web client │ └── warzone-mule/ # Mule binary (future) +├── warzone-phone/ # WZP submodule (voice/video) └── docs/ ``` -### warzone-protocol +--- -The protocol crate is the heart of the system. It is a pure library with zero I/O dependencies, used by all other crates (including WASM). +## Protocol Modules + +### warzone-protocol | Module | Purpose | |---------------|------------------------------------------------------| | `identity` | Seed, IdentityKeyPair, PublicIdentity, Fingerprint | -| `mnemonic` | BIP39 mnemonic encode/decode | +| `mnemonic` | BIP39 mnemonic encode/decode (24 words) | | `crypto` | HKDF-SHA256, ChaCha20-Poly1305 AEAD | | `prekey` | SignedPreKey, OneTimePreKey, PreKeyBundle | | `x3dh` | X3DH key agreement (initiate + respond) | -| `ratchet` | Double Ratchet state machine | -| `message` | WireMessage enum, MessageContent, ReceiptType | -| `session` | Session management types | -| `store` | Storage trait definitions | -| `sender_keys` | Sender Key protocol for groups | -| `history` | Encrypted backup/restore (HKDF → ChaCha20) | -| `ethereum` | secp256k1 identity, Ethereum address derivation | +| `ratchet` | Double Ratchet state machine (MAX_SKIP=1000) | +| `message` | WireMessage enum (8 variants), CallSignalType | +| `sender_keys` | Sender Key protocol for group encryption | +| `history` | Encrypted backup/restore | +| `ethereum` | secp256k1, Keccak-256, Ethereum address derivation | | `types` | Fingerprint, DeviceId, SessionId, MessageId | -| `errors` | ProtocolError enum | ### warzone-server -An axum HTTP + WebSocket server with sled embedded database. +| Module | Purpose | +|----------------------|---------------------------------------------------| +| `main` | CLI args, startup, federation init | +| `state` | AppState, Connections, CallState, DedupTracker | +| `db` | 7 sled trees: keys, messages, groups, aliases, tokens, calls, missed_calls | +| `federation` | Peer config, presence sync, message forwarding | +| `auth_middleware` | Bearer token extractor (401 on protected routes) | +| `routes/auth` | Challenge-response authentication | +| `routes/ws` | WebSocket relay + call signaling awareness | +| `routes/messages` | Send, poll (fetch-and-delete), ack | +| `routes/groups` | Create, join, leave, kick, members, send | +| `routes/calls` | Call CRUD, group call initiation | +| `routes/devices` | Device listing, kick, revoke-all | +| `routes/presence` | Online status (single + batch) | +| `routes/federation` | Peer presence sync + message forwarding | +| `routes/wzp` | WZP relay config + service token | +| `routes/aliases` | Alias CRUD with TTL + recovery keys | +| `routes/keys` | Pre-key bundle registration & retrieval | -| Module | Purpose | -|------------------|--------------------------------------------| -| `main` | CLI args, server startup (default :7700) | -| `state` | AppState, Connections map, DedupTracker | -| `db` | Database struct (5 sled trees) | -| `routes/keys` | Pre-key bundle registration & retrieval | -| `routes/messages`| Send, poll (fetch-and-delete), ack | -| `routes/groups` | Create, join, leave, kick, members, list | -| `routes/aliases` | Register, resolve, recover, renew, admin | -| `routes/auth` | Challenge-response authentication | -| `routes/ws` | WebSocket real-time message delivery | -| `routes/web` | Static file serving for web client | -| `routes/health` | Health check endpoint | +### warzone-client (TUI) -### warzone-client - -CLI and TUI client with local sled database for sessions, contacts, and history. - -| Module | Purpose | -|-------------|------------------------------------------------| -| `main` | CLI parser (clap): init, recover, info, etc. | -| `keystore` | Seed encryption at rest (Argon2id + ChaCha20) | -| `storage` | LocalDb: sessions, pre_keys, contacts, history | -| `net` | HTTP + WebSocket client | -| `tui/app` | TUI application (ratatui + crossterm) | -| `cli/*` | CLI subcommand handlers | +| Module | Purpose | +|--------------------|-------------------------------------------------| +| `tui/mod` | Event loop, run_tui() entry point | +| `tui/types` | App, ChatLine, scroll/connection state | +| `tui/draw` | Rendering: timestamps, scroll, status dot, badge | +| `tui/input` | Keyboard: text editing, scroll keys | +| `tui/commands` | /help, /call, /devices, /kick, 20+ commands | +| `tui/file_transfer`| Chunked file send (DM + group) | +| `tui/network` | WS/HTTP polling, group decrypt, session recovery | +| `storage` | LocalDb: sessions, pre_keys, contacts, history, sender_keys | ### warzone-wasm -WASM bridge exposing protocol functions to JavaScript via `wasm-bindgen`. - -| Export | Purpose | -|-------------------------|--------------------------------------------| -| `WasmIdentity` | Seed generation, fingerprint, bundle | -| `WasmSession` | Encrypt/decrypt with Double Ratchet | -| `create_receipt` | Build receipt WireMessages | -| `decrypt_wire_message` | Full message decryption pipeline | -| `self_test` | End-to-end crypto verification in WASM | -| `debug_bundle_info` | Bundle introspection for debugging | - -### warzone-mule (future) - -Placeholder for the mule binary — physical message relay for disconnected networks. +| Export | Purpose | +|-----------------------------------|--------------------------------------------| +| `WasmIdentity` | Seed generation, fingerprint, bundle | +| `WasmSession` | Encrypt/decrypt with Double Ratchet | +| `decrypt_wire_message` | Full message pipeline (all 8 variants) | +| `create_receipt` | Build receipt WireMessages | +| `decrypt_group_message` | Sender Key group decryption | +| `create_sender_key_from_distribution` | Build SenderKey from distribution | +| `self_test` | End-to-end crypto verification in WASM | --- ## Cryptographic Stack +```mermaid +graph TB + PLAIN["Plaintext Message"] --> DR["Double Ratchet
(per-message keys)"] + DR --> X3DH_INIT["X3DH Session Init
(3-4 DH operations)"] + X3DH_INIT --> AEAD["ChaCha20-Poly1305
(AEAD encryption)"] + AEAD --> SIGN["Ed25519 Signature
(pre-key signing)"] + SIGN --> WIRE["WireMessage
(bincode serialization)"] + WIRE --> TRANSPORT["HTTP POST / WS Binary"] + + style DR fill:#2d5016,color:#fff + style AEAD fill:#1a3a5c,color:#fff + style X3DH_INIT fill:#4a1a5c,color:#fff +``` + ### Primitives | Primitive | Crate | Purpose | @@ -136,396 +188,480 @@ Placeholder for the mule binary — physical message relay for disconnected netw | Ed25519 | `ed25519-dalek` | Signing, identity verification | | X25519 | `x25519-dalek` | Diffie-Hellman key exchange | | ChaCha20-Poly1305 | `chacha20poly1305` | Authenticated encryption (AEAD) | -| HKDF-SHA256 | `hkdf` + `sha2` | Key derivation | -| SHA-256 | `sha2` | Fingerprint computation | -| Argon2id | `argon2` | Passphrase-based key derivation | +| HKDF-SHA256 | `hkdf` + `sha2` | Key derivation with domain separation | +| SHA-256 | `sha2` | Fingerprints, file integrity, room hashing | +| Argon2id | `argon2` | Passphrase-based seed encryption at rest | | secp256k1 ECDSA | `k256` | Ethereum-compatible signing | | Keccak-256 | `tiny-keccak` | Ethereum address derivation | -### Protocol Stack - -``` -Application (plaintext) - │ - ▼ -Double Ratchet (per-message keys, forward secrecy) - │ - ▼ -X3DH (session establishment, 3-4 DH operations) - │ - ▼ -ChaCha20-Poly1305 (authenticated encryption) - │ - ▼ -Ed25519 (message signing + pre-key signing) - │ - ▼ -WireMessage (bincode serialization) - │ - ▼ -Transport (HTTP POST / WebSocket binary frame) -``` - --- -## Dual-Curve Identity Model +## Identity Derivation -Warzone derives two separate cryptographic identities from a single BIP39 seed: +```mermaid +graph LR + SEED["BIP39 Seed
(32 bytes, 24 words)"] + SEED -->|"HKDF(info='warzone-ed25519')"| ED["Ed25519 Signing Key"] + SEED -->|"HKDF(info='warzone-x25519')"| X25519["X25519 Encryption Key"] + SEED -->|"HKDF(info='warzone-secp256k1')"| SECP["secp256k1 Key"] + SEED -->|"HKDF(info='warzone-history')"| HIST["History Encryption Key"] -``` -BIP39 Seed (32 bytes, 24 words) - │ - ├─── HKDF(info="warzone-ed25519") ──→ Ed25519 signing keypair - │ │ - │ └─→ SHA-256[:16] = Fingerprint - │ - ├─── HKDF(info="warzone-x25519") ──→ X25519 encryption keypair - │ (used in X3DH + Double Ratchet) - │ - └─── HKDF(info="warzone-secp256k1") ──→ secp256k1 keypair - │ - └─→ Keccak-256[-20:] = Ethereum Address + ED -->|"SHA-256[:16]"| FP["Fingerprint
xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx"] + SECP -->|"Keccak-256[-20:]"| ETH["Ethereum Address
0x..."] ``` -**Messaging identity:** Ed25519 (signing) + X25519 (encryption). The fingerprint is `SHA-256(Ed25519_pubkey)[:16]`, displayed as `xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx`. - -**Ethereum identity:** secp256k1 keypair derived from the same seed with domain-separated HKDF. The Ethereum address is derived using standard `Keccak-256(uncompressed_pubkey[1:])[-20:]`. EIP-55 checksummed display is supported. - -This allows a single mnemonic to control both a Warzone messaging identity and an Ethereum wallet address. +A single mnemonic controls: messaging identity (Ed25519 + X25519), Ethereum wallet (secp256k1), and backup encryption. WarzonePhone uses the same seed with identical HKDF parameters for shared identity (verified by 15 cross-project tests). --- ## Wire Protocol -All messages between clients use the `WireMessage` enum, serialized with bincode: +### WireMessage Variants -```rust -enum WireMessage { - // Session establishment (X3DH + first ratchet message) - KeyExchange { - id, sender_fingerprint, sender_identity_encryption_key, - ephemeral_public, used_one_time_pre_key_id, ratchet_message, - }, +```mermaid +graph TB + WM["WireMessage (bincode)"] + WM --> KE["KeyExchange
X3DH + first ratchet msg"] + WM --> MSG["Message
Double Ratchet encrypted"] + WM --> REC["Receipt
Sent/Delivered/Read"] + WM --> FH["FileHeader
filename, size, SHA-256"] + WM --> FC["FileChunk
64KB encrypted chunks"] + WM --> GSK["GroupSenderKey
Sender Key encrypted"] + WM --> SKD["SenderKeyDistribution
Share key via 1:1 channel"] + WM --> CS["CallSignal
Offer/Answer/Hangup/..."] +``` - // Subsequent DM messages (Double Ratchet encrypted) - Message { id, sender_fingerprint, ratchet_message }, +### CallSignalType - // Delivery / read receipts (plaintext metadata) - Receipt { sender_fingerprint, message_id, receipt_type }, - - // File transfer: header announces the file - FileHeader { id, sender_fingerprint, filename, file_size, total_chunks, sha256 }, - - // File transfer: individual chunk (encrypted data) - FileChunk { id, sender_fingerprint, filename, chunk_index, total_chunks, data }, - - // Group message encrypted with Sender Key (O(1) encryption) - GroupSenderKey { id, sender_fingerprint, group_name, generation, counter, ciphertext }, - - // Sender Key distribution (sent via 1:1 encrypted channel) - SenderKeyDistribution { sender_fingerprint, group_name, chain_key, generation }, -} +``` +Offer | Answer | IceCandidate | Hangup | Reject | Ringing | Busy ``` ### Transport Encoding -- **CLI ↔ Server (HTTP):** JSON envelope with bincode message as byte array -- **CLI ↔ Server (WebSocket binary):** 64 hex chars (recipient fingerprint) + raw bincode bytes -- **Web ↔ Server (WebSocket JSON):** `{"to": "fingerprint", "message": [byte_array]}` +| Client | Path | Format | +|-----------|---------------|--------| +| CLI/TUI | WS binary | 64 hex chars (recipient fp) + raw bincode | +| CLI/TUI | HTTP POST | JSON envelope with bincode as byte array | +| Web | WS JSON | `{"to": "fingerprint", "message": [bytes]}` | +| Server↔Server | HTTP POST | JSON with base64 message + HMAC auth header | --- ## Server Architecture -### Framework - -- **axum 0.7** with tokio async runtime -- **sled 0.34** embedded database (zero-config, no external DB) -- **tower-http** for CORS and tracing middleware - -### Route Structure - -All API endpoints live under `/v1`: +### Route Map ``` -POST /v1/keys/register Register pre-key bundle -GET /v1/keys/:fingerprint Fetch pre-key bundle -POST /v1/keys/replenish Upload additional OTPKs -GET /v1/keys/:fp/otpk-count Check remaining OTPKs -GET /v1/keys/:fp/devices List registered devices -GET /v1/keys/list List all registered fingerprints +Auth-Protected (bearer token required): + POST /v1/messages/send Send encrypted message + POST /v1/groups/create|join|send|leave|kick + POST /v1/alias/register|unregister|recover|renew|admin-remove + POST /v1/keys/register|replenish + POST /v1/calls/initiate|:id/end + POST /v1/groups/:name/call Group call initiation + POST /v1/devices/:id/kick Kick a device + POST /v1/devices/revoke-all Panic button + POST /v1/presence/batch Bulk online check -POST /v1/messages/send Send encrypted message -GET /v1/messages/poll/:fp Fetch-and-delete queued messages -DELETE /v1/messages/:id/ack Explicit message acknowledgment +Public (no auth): + GET /v1/keys/:fp Fetch pre-key bundle + GET /v1/messages/poll/:fp Fetch queued messages + GET /v1/groups/:name|list|members + GET /v1/alias/resolve/:name|list|whois/:fp + GET /v1/calls/:id|active|missed + GET /v1/presence/:fp Online status + GET /v1/devices List own devices (auth) + GET /v1/wzp/relay-config WZP relay address + token + GET /v1/federation/status Federation health + GET /v1/ws/:fp WebSocket upgrade + POST /v1/auth/challenge|verify|validate -POST /v1/groups/create Create a group -GET /v1/groups List all groups -GET /v1/groups/:name Get group info -POST /v1/groups/:name/join Join a group -POST /v1/groups/:name/send Fan-out group message -POST /v1/groups/:name/leave Leave a group -POST /v1/groups/:name/kick Kick a member (creator only) -GET /v1/groups/:name/members List members with aliases - -POST /v1/alias/register Register an alias -POST /v1/alias/recover Recover alias with recovery key -POST /v1/alias/renew Heartbeat / renew TTL -GET /v1/alias/resolve/:name Resolve alias → fingerprint -GET /v1/alias/whois/:fp Reverse lookup fingerprint → alias -GET /v1/alias/list List all aliases -POST /v1/alias/unregister Remove your own alias -POST /v1/alias/admin-remove Admin: remove any alias - -POST /v1/auth/challenge Request a challenge -POST /v1/auth/verify Verify signature, get bearer token - -GET /v1/ws/:fingerprint WebSocket upgrade for real-time push - -GET / Web client static files -GET /health Health check +Federation (HMAC-authenticated, server-to-server): + POST /v1/federation/presence Presence sync + POST /v1/federation/forward Message forwarding ``` ### Message Routing -``` -Incoming message - │ - ├─→ Dedup check (bounded FIFO, 10,000 IDs) - │ │ - │ ├─→ Duplicate? → silently drop - │ └─→ New? → continue - │ - ├─→ Try WebSocket push to recipient - │ │ - │ ├─→ Connected? → instant delivery - │ └─→ Offline? → queue in sled DB - │ - └─→ Renew sender's alias TTL +```mermaid +flowchart TD + MSG["Incoming Message
for fingerprint X"] --> DEDUP{"Dedup Check
(10K FIFO)"} + DEDUP -->|Duplicate| DROP["Drop"] + DEDUP -->|New| LOCAL{"push_to_client(X)
Local WS?"} + LOCAL -->|Delivered| DONE["Done"] + LOCAL -->|Not local| FED{"Federation
enabled?"} + FED -->|No| QUEUE["Queue in
sled DB"] + FED -->|Yes| REMOTE{"X in remote
presence?"} + REMOTE -->|No| QUEUE + REMOTE -->|Yes| FORWARD["HTTP POST to peer
/v1/federation/forward"] + FORWARD -->|Success| DONE + FORWARD -->|Peer down| QUEUE + + style DONE fill:#2d5016,color:#fff + style DROP fill:#5c1a1a,color:#fff + style QUEUE fill:#4a3a1a,color:#fff ``` -### WebSocket Protocol +### WebSocket Lifecycle -1. Client connects to `/v1/ws/:fingerprint` -2. Server flushes any queued messages from DB -3. Server registers a push channel for this connection -4. Incoming WS messages are routed to recipient's WS or queued -5. Multiple devices per fingerprint are supported (all receive a copy) -6. On disconnect, stale senders are cleaned up +```mermaid +sequenceDiagram + participant C as Client + participant S as Server -### Dedup Tracker + C->>S: GET /v1/ws/:fingerprint + S->>S: Check connection cap (max 5) + S->>C: WS Upgrade -The `DedupTracker` prevents message duplication across HTTP and WebSocket delivery paths: + Note over S: Flush queued messages + S->>C: Binary(queued_msg_1) + S->>C: Binary(queued_msg_2) -- Bounded to 10,000 message IDs -- FIFO eviction when capacity is exceeded -- Uses `HashSet` for O(1) lookup + `VecDeque` for ordering -- Thread-safe via `std::sync::Mutex` + Note over S: Flush missed calls + S->>C: Text({"type":"missed_call",...}) + + Note over S: Register push channel + + loop Real-time + C->>S: Binary(64-hex-fp + bincode) + S->>S: Dedup + Call signal awareness + S->>S: deliver_or_queue(recipient) + end + + C->>S: Close + S->>S: Cleanup stale senders +``` --- -## Web Client Architecture (WASM) +## Federation -The web client runs the **exact same crypto** as the CLI through the `warzone-wasm` crate: +```mermaid +graph LR + subgraph ServerAlpha["Server Alpha"] + CA["Client A
Client B"] + FHA["Federation Handle"] + end -``` -┌─────────────────────────────────────────────────────┐ -│ Browser │ -│ │ -│ ┌──────────────────┐ ┌──────────────────────────┐ │ -│ │ JavaScript UI │ │ warzone-wasm (WASM) │ │ -│ │ (chat.html) │──│ WasmIdentity │ │ -│ │ │ │ WasmSession │ │ -│ │ WebSocket ◄─────┤ │ decrypt_wire_message() │ │ -│ │ localStorage │ │ create_receipt() │ │ -│ │ │ │ self_test() │ │ -│ └──────────────────┘ └──────────────────────────┘ │ -│ │ -│ Storage: │ -│ localStorage: seed_hex, spk_secret_hex │ -│ localStorage: session: (base64 ratchet state) │ -└─────────────────────────────────────────────────────┘ + subgraph ServerBravo["Server Bravo"] + CC["Client C
Client D"] + FHB["Federation Handle"] + end + + FHA <-->|"Presence sync
(every 5s)"| FHB + FHA -->|"Forward message
(HTTP POST)"| FHB + FHB -->|"Forward message
(HTTP POST)"| FHA ``` -Key design decisions: -- **Web Crypto** provides cryptographic randomness (`OsRng` in WASM maps to `crypto.getRandomValues`) -- **No OTPKs** in the web client — cannot reliably store secrets. X3DH works without DH4 (OTPKs are an anti-replay optimization, not a security requirement) -- **SPK secret** stored in localStorage as hex, restored on page load -- **Session state** serialized as base64 bincode, stored per-peer in localStorage -- **bincode** wire format ensures CLI ↔ Web interoperability +### Configuration + +Each server has a `federation.json`: + +```json +{ + "server_id": "alpha", + "shared_secret": "long-random-string-shared-between-both", + "peer": { + "id": "bravo", + "url": "http://10.0.0.2:7700" + }, + "presence_interval_secs": 5 +} +``` + +Start with: `warzone-server --federation federation.json` + +### Presence Sync + +Every 5 seconds, each server POSTs its connected fingerprint list to the peer: + +``` +POST /v1/federation/presence +X-Federation-Token: SHA-256(secret || body) +{ "server_id": "alpha", "fingerprints": ["aabb...", "ccdd..."], "timestamp": ... } +``` + +The receiving server replaces its remote presence set entirely. If 3 intervals pass without a sync, the remote set is cleared (peer assumed down). + +### Message Forwarding + +```mermaid +sequenceDiagram + participant A as Client A (Alpha) + participant SA as Server Alpha + participant SB as Server Bravo + participant C as Client C (Bravo) + + A->>SA: Send message to C + SA->>SA: push_to_client(C) — not local + SA->>SA: remote_presence.contains(C) — yes + SA->>SB: POST /v1/federation/forward
X-Federation-Token: HMAC + SB->>SB: Verify HMAC + SB->>C: push_to_client(C) via WS + SB->>SA: { "delivered": true } +``` + +### Degradation + +| Scenario | Behavior | +|----------|----------| +| Peer unreachable | Message queued locally, retried on next connection | +| Presence stale (>15s) | Remote fingerprints cleared, treated as offline | +| Peer restarts | Presence repopulates within 5 seconds | +| HMAC mismatch | Request rejected with 401 | --- -## Data Flow Diagrams +## Call Infrastructure (WZP Integration) -### 1:1 Direct Message (First Message) +```mermaid +sequenceDiagram + participant Caller as Caller (TUI) + participant FC as featherChat Server + participant WZP as WZP Relay -``` -Alice Server Bob - │ │ │ - │ GET /v1/keys/:bob_fp │ │ - │─────────────────────────────→│ │ - │ ← PreKeyBundle (bincode) │ │ - │ │ │ - │ X3DH initiate(bundle) │ │ - │ → shared_secret │ │ - │ Double Ratchet init_alice() │ │ - │ ratchet.encrypt("hello") │ │ - │ │ │ - │ WireMessage::KeyExchange │ │ - │ POST /v1/messages/send │ │ - │─────────────────────────────→│ push via WS (or queue) │ - │ │─────────────────────────────→│ - │ │ │ - │ │ X3DH respond(spk_secret) │ - │ │ → shared_secret │ - │ │ init_bob() │ - │ │ ratchet.decrypt() │ - │ │ → "hello" │ + Caller->>FC: WireMessage::CallSignal(Offer) + FC->>FC: Create CallState(Ringing) + FC->>FC: push_to_client(callee) + + alt Callee online + FC-->>Callee: CallSignal(Offer) via WS + Callee->>FC: CallSignal(Answer) + FC->>FC: Update CallState(Active) + Note over Caller,WZP: Both connect to WZP Relay with bearer token + Caller->>WZP: QUIC + AuthToken + Handshake + Callee->>WZP: QUIC + AuthToken + Handshake + Note over WZP: Encrypted media flows (ChaCha20-Poly1305) + else Callee offline + FC->>FC: Record missed call in sled + Note over FC: Flushed on callee's next WS connect + end + + Caller->>FC: CallSignal(Hangup) + FC->>FC: Update CallState(Ended) ``` -### 1:1 Direct Message (Subsequent) +### Server Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `POST /v1/calls/initiate` | Create call (returns call_id) | +| `GET /v1/calls/:id` | Get call state | +| `POST /v1/calls/:id/end` | End a call | +| `GET /v1/calls/active` | List active calls | +| `POST /v1/calls/missed` | Get & clear missed calls | +| `POST /v1/groups/:name/call` | Group call (fan-out to members) | +| `GET /v1/presence/:fp` | Check if peer is online | +| `GET /v1/wzp/relay-config` | Get relay address + service token | + +### Group Call Room ID ``` -Alice Server Bob - │ │ │ - │ ratchet.encrypt("hi again") │ │ - │ WireMessage::Message │ │ - │ ─── WS binary ─────────────→│ ─── WS push ───────────────→│ - │ │ │ - │ │ ratchet.decrypt()│ - │ │ → "hi again" │ - │ │ │ - │ │ WireMessage::Receipt │ - │ │←──────────────────────────── │ - │←─────────────────────────────│ │ - │ ✓✓ delivered │ │ +room_id = hex(SHA-256("featherchat-group:" + group_name)[:16]) ``` -### Group Message (Sender Keys) +Deterministic, 32 hex chars. Prevents leaking group name to relay via QUIC SNI. -``` -Alice Server Bob, Carol - │ │ │ - │ SenderKey::generate("ops") │ │ - │ Distribute via 1:1 channels: │ │ - │ encrypt(SK_dist, Bob) │ │ - │ encrypt(SK_dist, Carol) │ │ - │ ─────────────────────────────→│──── push to Bob,Carol ──→│ - │ │ │ - │ sender_key.encrypt("attack") │ │ - │ WireMessage::GroupSenderKey │ │ - │ POST /groups/ops/send │ │ - │ ─────────────────────────────→│──── fan-out ────────────→│ - │ │ │ - │ │ bob_copy.decrypt() │ - │ │ carol_copy.decrypt() │ - │ │ → "attack" │ +--- + +## Device Management + +```mermaid +flowchart LR + USER["User with
3 devices"] --> LIST["GET /v1/devices
(lists all sessions)"] + USER --> KICK["POST /v1/devices/:id/kick
(force-close one)"] + USER --> REVOKE["POST /v1/devices/revoke-all
(nuke all except current)"] + + KICK --> CLOSE["WS channel closed
+ token invalidated"] + REVOKE --> NUKE["All WS closed
+ all tokens cleared"] ``` -### File Transfer +- Max 5 WS connections per fingerprint +- Stale connections auto-cleaned on new registrations +- `/devices` and `/kick ` available as TUI commands -``` -Sender Server Recipient - │ │ │ - │ Read file, compute SHA-256 │ │ - │ Split into 64KB chunks │ │ - │ │ │ - │ WireMessage::FileHeader │ │ - │ ─────────────────────────────→│──── push ────────────────→│ - │ │ │ - │ WireMessage::FileChunk[0] │ │ - │ ─────────────────────────────→│──── push ────────────────→│ - │ WireMessage::FileChunk[1] │ │ - │ ─────────────────────────────→│──── push ────────────────→│ - │ ... │ │ - │ WireMessage::FileChunk[N-1] │ │ - │ ─────────────────────────────→│──── push ────────────────→│ - │ │ │ - │ │ Reassemble chunks │ - │ │ Verify SHA-256 │ - │ │ Save to downloads/ │ -``` +--- -File constraints: max 10 MB, 64 KB chunks. +## Security Model + +### What's Protected + +| Layer | Protection | Mechanism | +|-------|-----------|-----------| +| Message content | E2E encrypted | ChaCha20-Poly1305 via Double Ratchet | +| Forward secrecy | Per-message keys | DH ratchet step on direction change | +| Session establishment | Authenticated | X3DH with signed pre-keys | +| Identity | Deterministic from seed | HKDF with domain separation | +| Seed at rest | Encrypted | Argon2id passphrase KDF | +| API writes | Auth-gated | Bearer token middleware (401) | +| Inter-server | Authenticated | SHA-256(secret \|\| body) token | +| WS connections | Rate-limited | 5 per fingerprint, 200 global | +| WZP relay | Token-gated | featherChat bearer token validation | + +### What's NOT Protected (Phase 1 scope) + +| Data | Exposure | +|------|----------| +| Sender/recipient metadata | Server sees routing info | +| Message timing | Server sees timestamps | +| Online/offline status | Server tracks WS connections | +| Group membership | Server stores plaintext member list | +| IP addresses | Server logs (standard for HTTP) | + +Planned mitigations: sealed sender (Phase 6), onion routing, metadata encryption. + +### Trust Boundaries + +```mermaid +graph TB + subgraph TRUSTED["Trusted: Your Device"] + SEED["Seed in memory"] + LDB["Local sled DB"] + end + + subgraph SEMI["Semi-Trusted: Server"] + SRVR["Sees metadata
Can't read messages"] + end + + subgraph UNTRUSTED["Untrusted: Network"] + NET["TLS protects transport"] + end + + TRUSTED -->|"E2E encrypted + TLS"| SEMI + SEMI -->|"TLS"| UNTRUSTED +``` --- ## Storage Model -### Server sled Trees +### Server sled Trees (7) -| Tree | Key Format | Value | Purpose | -|------------|-------------------------------|--------------------------|------------------------------| -| `keys` | `` | bincode PreKeyBundle | Identity + pre-key storage | -| `keys` | `device::` | bincode PreKeyBundle | Per-device bundles | -| `keys` | `otpk::` | hex pubkey | One-time pre-keys | -| `messages` | `queue::` | raw bincode WireMessage | Offline message queue | -| `groups` | `` | JSON GroupInfo | Group membership | -| `aliases` | `a:` | fingerprint string | Forward lookup | -| `aliases` | `fp:` | alias string | Reverse lookup | -| `aliases` | `rec:` | JSON AliasRecord | Full record (TTL, recovery) | -| `tokens` | `` | JSON {fp, expires_at} | Auth bearer tokens | +| Tree | Key Format | Value | +|----------------|---------------------------|--------------------------| +| `keys` | `` | bincode PreKeyBundle | +| `messages` | `queue::` | raw bincode WireMessage | +| `groups` | `` | JSON GroupInfo | +| `aliases` | `a:`, `fp:`, `rec:` | Various | +| `tokens` | `` | JSON {fp, expires_at} | +| `calls` | `` | JSON CallState | +| `missed_calls` | `missed::` | JSON {caller, timestamp} | -### Client sled Trees +### Client sled Trees (5) -| Tree | Key Format | Value | Purpose | -|-------------|-------------------------------|--------------------------|----------------------------| -| `sessions` | `` | bincode RatchetState | Double Ratchet sessions | -| `pre_keys` | `spk:` | 32-byte StaticSecret | Signed pre-key secrets | -| `pre_keys` | `otpk:` | 32-byte StaticSecret | One-time pre-key secrets | -| `contacts` | `` | JSON contact record | Contact list | -| `history` | `hist:::` | JSON message record | Message history | - -### Client Filesystem - -``` -~/.warzone/ -├── identity.seed # WZS1 magic + salt(16) + nonce(12) + ciphertext(48) -│ # or plain 32 bytes (legacy/testing) -└── db/ # sled database directory - ├── conf - ├── db - └── ... -``` - -The `WARZONE_HOME` environment variable overrides `~/.warzone`. +| Tree | Key Format | Value | +|----------------|---------------------------|--------------------------| +| `sessions` | `` | bincode RatchetState | +| `pre_keys` | `spk:`, `otpk:` | 32-byte StaticSecret | +| `contacts` | `` | JSON contact record | +| `history` | `hist:::` | JSON message record | +| `sender_keys` | `sk::` | bincode SenderKey | --- -## Extensibility Points +## Test Coverage + +| Crate | Tests | Coverage | +|-------|------:|---------| +| warzone-protocol | 28 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic | +| warzone-client (types) | 10 | App init, scroll, connected, timestamps, normfp | +| warzone-client (input) | 25 | Text editing, cursor movement, scroll keys, quit | +| warzone-client (draw) | 9 | Rendering, timestamps, connection dot, scroll, unread badge | +| **Total** | **72** | All passing | + +WZP side: 15 cross-project identity tests + 17 integration tests (separate repo). + +--- + +## Data Flow Diagrams + +### 1:1 Direct Message (First Contact) + +```mermaid +sequenceDiagram + participant A as Alice + participant S as Server + participant B as Bob + + A->>S: GET /v1/keys/:bob_fp + S->>A: PreKeyBundle (bincode) + + Note over A: X3DH initiate(bundle)
Double Ratchet init_alice()
ratchet.encrypt("hello") + + A->>S: WireMessage::KeyExchange + S->>B: Push via WS (or queue) + + Note over B: X3DH respond(spk_secret)
init_bob()
ratchet.decrypt() = "hello" + + B->>S: WireMessage::Receipt(Delivered) + S->>A: Push receipt +``` + +### Group Message (Sender Keys) + +```mermaid +sequenceDiagram + participant A as Alice + participant S as Server + participant B as Bob + participant C as Carol + + Note over A: SenderKey::generate("ops") + + A->>S: SenderKeyDistribution (via 1:1 to Bob) + S->>B: Push distribution + A->>S: SenderKeyDistribution (via 1:1 to Carol) + S->>C: Push distribution + + Note over A: sender_key.encrypt("attack") + + A->>S: POST /groups/ops/send (GroupSenderKey) + S->>B: Fan-out + S->>C: Fan-out + + Note over B,C: sender_key.decrypt() = "attack" +``` + +### Federated Message + +```mermaid +sequenceDiagram + participant A as Client A (Alpha) + participant SA as Server Alpha + participant SB as Server Bravo + participant C as Client C (Bravo) + + Note over SA,SB: Presence sync (every 5s) + SA->>SB: POST /federation/presence [A, B] + SB->>SA: POST /federation/presence [C, D] + + A->>SA: Message for C + SA->>SA: Not local, C in remote presence + SA->>SB: POST /federation/forward (HMAC auth) + SB->>C: Push via local WS + SB->>SA: { "delivered": true } +``` + +--- + +## Extensibility ### Adding New WireMessage Variants -1. Add a new variant to `WireMessage` in `warzone-protocol/src/message.rs` -2. Update `extract_message_id()` in both `routes/messages.rs` and `routes/ws.rs` -3. Handle in the TUI poll loop (`tui/app.rs`) -4. Handle in `decrypt_wire_message()` in `warzone-wasm/src/lib.rs` -5. bincode serialization is automatic (enum tag + fields) - -### Adding New Transport Traits (future) - -The design document specifies a `Transport` trait: - -```rust -trait Transport { - async fn send(&self, endpoint: &str, blob: &[u8]) -> Result<()>; - async fn recv(&self) -> Result>; -} -``` - -Current transports: HTTPS (reqwest) and WebSocket (axum/tungstenite). Planned: Bluetooth, LoRa, Wi-Fi Direct, USB/file. - -### Adding New Storage Backends - -Client storage currently uses sled directly. The pattern for abstraction: -- `LocalDb` in `storage.rs` already provides a clean API -- Replace sled calls with trait methods for alternative backends (SQLite, IndexedDB, etc.) -- Server's `Database` in `db.rs` follows the same pattern +1. Add variant to `WireMessage` in `warzone-protocol/src/message.rs` +2. Update `extract_message_id()` in `routes/messages.rs` and `routes/ws.rs` +3. Handle in `tui/network.rs` (process_wire_message) +4. Handle in `warzone-wasm/src/lib.rs` (decrypt_wire_message) +5. bincode serialization is automatic ### Adding New Server Routes -1. Create a module in `routes/` +1. Create module in `routes/` 2. Implement `pub fn routes() -> Router` -3. Merge into the router in `routes/mod.rs` -4. Access shared state via `State(state): State` +3. Merge in `routes/mod.rs` +4. Add `_auth: AuthFingerprint` for write endpoints + +### Adding Federation Peers (Future) + +Current: 1 peer via JSON config. Future: N peers via config array or DNS discovery. The `deliver_or_queue()` method would iterate over peers checking remote presence. diff --git a/warzone/docs/PROGRESS.md b/warzone/docs/PROGRESS.md index 16d1644..82e1953 100644 --- a/warzone/docs/PROGRESS.md +++ b/warzone/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Warzone Messenger (featherChat) — Progress Report -**Current Version:** 0.0.20 +**Current Version:** 0.0.21 **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 | | 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 | Metric | Value | |-------------------|--------------------------------| | 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 | | Min Rust version | 1.75 | | License | MIT | @@ -91,7 +117,7 @@ Built on the Phase 1 foundation to deliver a complete messaging experience: | prekey | Signed + one-time pre-keys | | x3dh | Extended Triple Diffie-Hellman | | ratchet | Double Ratchet state machine | -| message | WireMessage (7 variants), content types| +| message | WireMessage (8 variants incl. CallSignal)| | sender_keys | Sender Key encrypt/decrypt/rotate | | history | Encrypted backup format | | ethereum | secp256k1, Keccak-256, EIP-55 | @@ -121,18 +147,29 @@ Built on the Phase 1 foundation to deliver a complete messaging experience: ## Test Suite -28 tests across the protocol crate: +72 tests across protocol + client crates: + +### Protocol Tests (28) | Module | Tests | Coverage | |---------------|-------|---------------------------------------------| | identity | 3 | Deterministic derivation, mnemonic roundtrip, fingerprint format | | crypto | 4 | AEAD roundtrip, wrong key, wrong AAD, HKDF determinism | -| x3dh | ~4 | Initiate/respond, shared secret match, with/without OTPK | -| ratchet | ~6 | Encrypt/decrypt, out-of-order, multiple messages, ping-pong | +| x3dh | 1 | Shared secret match between Alice and Bob | +| ratchet | 5 | Basic, bidirectional, multiple, out-of-order, 100 messages | | sender_keys | 4 | Basic encrypt/decrypt, multiple messages, rotation, old key rejection | | ethereum | 5 | Deterministic derivation, address format, checksum, sign/verify, different seeds | | 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) - PWA: service worker, offline shell, install prompt -### Priority Order +### Priority Order (Updated v0.0.21) -1. Federation (Phase 3) — enables multi-server deployment -2. Mule protocol (Phase 4) — core differentiator for warzone use -3. Sealed sender (Phase 6) — strongest metadata privacy -4. Push notifications (Phase 7) — usability for mobile/desktop -5. Transport fallbacks (Phase 5) — Bluetooth, LoRa -6. Polish (Phase 7) — rate limiting, admin tools, CI +1. **Security (FC-P1)** — auth enforcement, rate limiting, device revocation +2. **TUI call integration (FC-P2)** — /call, /accept, /hangup commands +3. **Web call integration (FC-P3)** — WASM CallSignal + browser call UI +4. **Protocol hardening (FC-P4)** — session/message versioning +5. Federation (Phase 3) — multi-server deployment +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. diff --git a/warzone/docs/TASK_PLAN.md b/warzone/docs/TASK_PLAN.md new file mode 100644 index 0000000..499e612 --- /dev/null +++ b/warzone/docs/TASK_PLAN.md @@ -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 ` 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 ` 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 | diff --git a/warzone/federation.example.json b/warzone/federation.example.json new file mode 100644 index 0000000..1fc6431 --- /dev/null +++ b/warzone/federation.example.json @@ -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 +}