From 3e0889e5dc321594d82109601de3bf0d4b1120ce Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sat, 28 Mar 2026 16:45:58 +0400 Subject: [PATCH 01/50] v0.0.21: TUI overhaul, WZP call infrastructure, security hardening, federation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI: - Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod) - Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge - /help command, terminal bell on incoming DM, /devices + /kick commands - 44 unit tests (types, input, draw with TestBackend) Server — WZP Call Infrastructure (FC-2/3/5/6/7/10): - Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees) - WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline) - Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out) - Presence API (GET /presence/:fp, POST /presence/batch) - Missed call flush on WS reconnect - WZP relay config + CORS Server — Security (FC-P1): - Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers) - Session auto-recovery (delete corrupted ratchet, show [session reset]) - WS connection cap (5/fingerprint) + global concurrency limit (200) - Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all) Server — Federation: - Two-server federation via JSON config (--federation flag) - Periodic presence sync (every 5s, full-state, self-healing) - Message forwarding via HTTP POST with SHA-256(secret||body) auth - Graceful degradation (peer down = queue locally) - deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs Client — Group Messaging: - SenderKeyDistribution storage + GroupSenderKey decryption in TUI - sender_keys sled tree in LocalDb WASM: - All 8 WireMessage variants handled (no more "unsupported") - decrypt_group_message() + create_sender_key_from_distribution() exports - CallSignal parsing with signal_type mapping Docs: - ARCHITECTURE.md rewritten with Mermaid diagrams - README.md created - TASK_PLAN.md with FC-P{phase}-T{task} naming - PROGRESS.md updated to v0.0.21 WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done) Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone-phone | 2 +- warzone/Cargo.lock | 190 +- warzone/Cargo.toml | 2 +- warzone/README.md | 165 ++ warzone/crates/warzone-client/src/storage.rs | 44 + warzone/crates/warzone-client/src/tui/app.rs | 1756 ----------------- .../crates/warzone-client/src/tui/commands.rs | 798 ++++++++ warzone/crates/warzone-client/src/tui/draw.rs | 377 ++++ .../warzone-client/src/tui/file_transfer.rs | 292 +++ .../crates/warzone-client/src/tui/input.rs | 377 ++++ warzone/crates/warzone-client/src/tui/mod.rs | 73 +- .../crates/warzone-client/src/tui/network.rs | 538 +++++ .../crates/warzone-client/src/tui/types.rs | 220 +++ warzone/crates/warzone-server/Cargo.toml | 2 + .../warzone-server/src/auth_middleware.rs | 84 + warzone/crates/warzone-server/src/db.rs | 6 + .../crates/warzone-server/src/federation.rs | 212 ++ warzone/crates/warzone-server/src/lib.rs | 2 + warzone/crates/warzone-server/src/main.rs | 35 +- .../warzone-server/src/routes/aliases.rs | 5 + .../crates/warzone-server/src/routes/calls.rs | 233 +++ .../warzone-server/src/routes/devices.rs | 102 + .../warzone-server/src/routes/federation.rs | 144 ++ .../warzone-server/src/routes/groups.rs | 5 + .../crates/warzone-server/src/routes/keys.rs | 2 + .../warzone-server/src/routes/messages.rs | 12 +- .../crates/warzone-server/src/routes/mod.rs | 10 + .../warzone-server/src/routes/presence.rs | 57 + .../crates/warzone-server/src/routes/ws.rs | 127 +- .../crates/warzone-server/src/routes/wzp.rs | 45 + warzone/crates/warzone-server/src/state.rs | 166 +- warzone/crates/warzone-wasm/src/lib.rs | 138 +- warzone/docs/ARCHITECTURE.md | 930 +++++---- warzone/docs/PROGRESS.md | 70 +- warzone/docs/TASK_PLAN.md | 239 +++ warzone/federation.example.json | 9 + 36 files changed, 5237 insertions(+), 2232 deletions(-) create mode 100644 warzone/README.md delete mode 100644 warzone/crates/warzone-client/src/tui/app.rs create mode 100644 warzone/crates/warzone-client/src/tui/commands.rs create mode 100644 warzone/crates/warzone-client/src/tui/draw.rs create mode 100644 warzone/crates/warzone-client/src/tui/file_transfer.rs create mode 100644 warzone/crates/warzone-client/src/tui/input.rs create mode 100644 warzone/crates/warzone-client/src/tui/network.rs create mode 100644 warzone/crates/warzone-client/src/tui/types.rs create mode 100644 warzone/crates/warzone-server/src/auth_middleware.rs create mode 100644 warzone/crates/warzone-server/src/federation.rs create mode 100644 warzone/crates/warzone-server/src/routes/calls.rs create mode 100644 warzone/crates/warzone-server/src/routes/devices.rs create mode 100644 warzone/crates/warzone-server/src/routes/federation.rs create mode 100644 warzone/crates/warzone-server/src/routes/presence.rs create mode 100644 warzone/crates/warzone-server/src/routes/wzp.rs create mode 100644 warzone/docs/TASK_PLAN.md create mode 100644 warzone/federation.example.json 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 +} From f8eaf30bb43e343c2b89fc79adaec90bbcfc439c Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sat, 28 Mar 2026 16:56:13 +0400 Subject: [PATCH 02/50] refactor: federation uses persistent WS instead of HTTP polling - Server-to-server communication via WebSocket at /v1/federation/ws - Auth as first WS frame (shared secret), presence + forwards over same connection - Auto-reconnect every 3s on disconnect, instant presence push on connect - Replaces HTTP REST polling (no more 5s intervals, lower latency) - Removed dead HMAC helpers (auth is now direct secret comparison over WS) - Simplified ARCHITECTURE.md mermaid diagrams for Gitea rendering Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 43 ++- warzone/Cargo.toml | 3 + warzone/crates/warzone-server/Cargo.toml | 1 + .../crates/warzone-server/src/federation.rs | 280 ++++++++++-------- warzone/crates/warzone-server/src/main.rs | 10 +- .../warzone-server/src/routes/federation.rs | 217 +++++++------- warzone/docs/ARCHITECTURE.md | 116 +++----- 7 files changed, 364 insertions(+), 306 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 0bd3fd2..f2bd0a8 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -163,7 +163,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower 0.5.3", "tower-layer", "tower-service", @@ -1795,7 +1795,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2593,6 +2593,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.21.0", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -2604,7 +2618,7 @@ dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tungstenite", + "tungstenite 0.24.0", ] [[package]] @@ -2766,6 +2780,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.24.0" @@ -2943,7 +2977,7 @@ dependencies = [ "sha2", "sled", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tracing", "tracing-subscriber", "url", @@ -3008,6 +3042,7 @@ dependencies = [ "sled", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.21.0", "tower 0.4.13", "tower-http 0.5.2", "tracing", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 2d1fa5c..da33c53 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -78,5 +78,8 @@ base64 = "0.22" # UUID uuid = { version = "1", features = ["v4", "serde"] } +# WebSocket client +tokio-tungstenite = { version = "0.21", features = ["native-tls"] } + # Zero secrets in memory zeroize = { version = "1", features = ["derive"] } diff --git a/warzone/crates/warzone-server/Cargo.toml b/warzone/crates/warzone-server/Cargo.toml index 8753ffe..a6bb3b5 100644 --- a/warzone/crates/warzone-server/Cargo.toml +++ b/warzone/crates/warzone-server/Cargo.toml @@ -27,3 +27,4 @@ ed25519-dalek.workspace = true bincode.workspace = true sha2.workspace = true reqwest = { workspace = true, features = ["rustls-tls", "json"] } +tokio-tungstenite.workspace = true diff --git a/warzone/crates/warzone-server/src/federation.rs b/warzone/crates/warzone-server/src/federation.rs index 3c5500a..6566562 100644 --- a/warzone/crates/warzone-server/src/federation.rs +++ b/warzone/crates/warzone-server/src/federation.rs @@ -1,12 +1,12 @@ -//! Federation: two-server message relay with shared-secret authentication. +//! Federation: two-server message relay via persistent WebSocket. //! -//! Each server periodically announces its connected clients to the peer. -//! When a message is destined for a remote client, it's forwarded via HTTP. +//! Each server maintains a WS connection to its peer. Presence updates +//! and message forwards flow over this single connection. Reconnects +//! automatically on failure. 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)] @@ -14,8 +14,6 @@ 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)] @@ -24,9 +22,7 @@ pub struct PeerConfig { pub url: String, } -fn default_interval() -> u64 { 5 } - -/// Load federation config from a JSON file. Returns None if path is empty. +/// Load federation config from a JSON file. 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))?; @@ -38,175 +34,227 @@ pub fn load_config(path: &str) -> anyhow::Result { /// 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, + pub connected: bool, } impl RemotePresence { - pub fn new(peer_url: String, peer_id: String) -> Self { + pub fn new(peer_id: String) -> Self { RemotePresence { - peer_url, peer_id, fingerprints: HashSet::new(), last_updated: 0, + connected: false, } } - /// 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) + self.connected && self.fingerprints.contains(fp) } } +/// Sender for outgoing federation messages over the WS. +pub type FederationSender = Arc>>>; + /// Handle for communicating with the federation peer. #[derive(Clone)] pub struct FederationHandle { pub config: FederationConfig, - pub client: reqwest::Client, pub remote_presence: Arc>, + /// Channel to send messages over the outgoing WS to the peer. + pub outgoing: FederationSender, } 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 } + FederationHandle { + config, + remote_presence, + outgoing: Arc::new(Mutex::new(None)), + } } /// 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) + rp.contains(fp) } - /// Forward a message to the peer server for delivery. - /// Returns true if the peer accepted it. + /// Forward a message to the peer server via the persistent WS. 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!({ + let msg = serde_json::json!({ + "type": "forward", "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 - } - } + self.send_json(msg).await } - /// 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!({ + /// Push local presence to peer via the persistent WS. + pub async fn push_presence(&self, fingerprints: Vec) -> bool { + let msg = serde_json::json!({ + "type": "presence", "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()); + self.send_json(msg).await + } - 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 - } + /// Send a JSON message over the outgoing WS channel. + async fn send_json(&self, msg: serde_json::Value) -> bool { + let guard = self.outgoing.lock().await; + if let Some(ref tx) = *guard { + let json_str = serde_json::to_string(&msg).unwrap_or_default(); + tx.send(json_str).is_ok() + } else { + false } } } -/// Background task: periodically sync presence with peer. -pub async fn presence_sync_loop( +/// Background task: connect to peer's WS endpoint, send auth, then loop. +/// Handles reconnection on failure. +pub async fn outgoing_ws_loop( handle: FederationHandle, - connections: crate::state::Connections, + state: crate::state::AppState, ) { - 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 - ); + let ws_url = handle.config.peer.url + .replace("http://", "ws://") + .replace("https://", "wss://"); + let ws_url = format!("{}/v1/federation/ws", ws_url); loop { - // Collect local fingerprints - let fps: Vec = { - let conns = connections.lock().await; - conns.keys().cloned().collect() - }; + tracing::info!("Federation: connecting to peer {} at {}", handle.config.peer.id, ws_url); - // 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); - } + match tokio_tungstenite::connect_async(&ws_url).await { + Ok((ws_stream, _)) => { + tracing::info!("Federation: connected to peer {}", 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(); + use futures_util::{SinkExt, StreamExt}; + let (mut ws_tx, mut ws_rx) = ws_stream.split(); + + // Send auth as first message + let auth_msg = serde_json::json!({ + "type": "auth", + "secret": handle.config.shared_secret, + "server_id": handle.config.server_id, + }); + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text( + serde_json::to_string(&auth_msg).unwrap_or_default() + )).await.is_err() { + tracing::warn!("Federation: failed to send auth to peer"); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + continue; + } + + // Set up outgoing channel + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + { + let mut guard = handle.outgoing.lock().await; + *guard = Some(out_tx); + } + { + let mut rp = handle.remote_presence.lock().await; + rp.connected = true; + } + + // Send initial presence + let fps: Vec = { + let conns = state.connections.lock().await; + conns.keys().cloned().collect() + }; + let _ = handle.push_presence(fps).await; + + // Spawn task to forward outgoing channel to WS + let send_task = tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(msg)).await.is_err() { + break; + } + } + }); + + // Read incoming messages from peer + while let Some(Ok(msg)) = ws_rx.next().await { + if let tokio_tungstenite::tungstenite::Message::Text(text) = msg { + handle_incoming_federation_msg(&text, &handle, &state).await; + } + } + + // Connection lost + send_task.abort(); + { + let mut guard = handle.outgoing.lock().await; + *guard = None; + } + { + let mut rp = handle.remote_presence.lock().await; + rp.connected = false; + rp.fingerprints.clear(); + } + tracing::warn!("Federation: lost connection to peer {}, reconnecting...", handle.config.peer.id); + } + Err(e) => { + tracing::warn!("Federation: failed to connect to peer {}: {}", handle.config.peer.id, e); } } - tokio::time::sleep(interval).await; + tokio::time::sleep(std::time::Duration::from_secs(3)).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()) +/// Process a single incoming JSON message from the federated peer WS. +async fn handle_incoming_federation_msg( + text: &str, + handle: &FederationHandle, + state: &crate::state::AppState, +) { + let parsed: serde_json::Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => return, + }; + + let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + match msg_type { + "presence" => { + 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("?"); + let count = fingerprints.len(); + + let mut rp = handle.remote_presence.lock().await; + rp.fingerprints = fingerprints.into_iter().collect(); + rp.last_updated = chrono::Utc::now().timestamp(); + tracing::debug!("Federation: received {} fingerprints from {}", count, server_id); + } + "forward" => { + let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or(""); + let message_b64 = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); + let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("?"); + + if let Ok(message) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) { + let delivered = state.push_to_client(to, &message).await; + if !delivered { + let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4()); + let _ = state.db.messages.insert(key.as_bytes(), message.as_slice()); + tracing::info!("Federation: queued message from {} for offline {}", from_server, to); + } else { + tracing::debug!("Federation: delivered message from {} to {}", from_server, to); + } + } + } + _ => { + tracing::debug!("Federation: unknown message type '{}'", msg_type); + } + } } -/// 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/main.rs b/warzone/crates/warzone-server/src/main.rs index 93d2c9c..418a120 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -49,12 +49,12 @@ async fn main() -> anyhow::Result<()> { 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(); + // Spawn federation outgoing WS connection if enabled + if let Some(ref fed) = state.federation { + let handle = fed.clone(); + let fed_state = state.clone(); tokio::spawn(async move { - federation::presence_sync_loop(handle, connections).await; + federation::outgoing_ws_loop(handle, fed_state).await; }); } diff --git a/warzone/crates/warzone-server/src/routes/federation.rs b/warzone/crates/warzone-server/src/routes/federation.rs index 3d2d718..a9b34eb 100644 --- a/warzone/crates/warzone-server/src/routes/federation.rs +++ b/warzone/crates/warzone-server/src/routes/federation.rs @@ -1,124 +1,143 @@ -//! Federation route handlers: receive presence updates and forwarded messages from peer server. +//! Federation route handlers: WS endpoint for peer servers + status. use axum::{ - body::Bytes, - extract::State, - http::{HeaderMap, StatusCode}, + extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}}, response::IntoResponse, - routing::post, + routing::get, Json, Router, }; +use futures_util::{SinkExt, StreamExt}; 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)) + .route("/federation/ws", get(federation_ws_handler)) + .route("/federation/status", 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( +/// WebSocket endpoint for incoming peer server connections. +async fn federation_ws_handler( + ws: WebSocketUpgrade, 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(); - } + ws.on_upgrade(move |socket| handle_peer_ws(socket, state)) +} - 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(), +/// Handle an incoming federation WS connection from the peer server. +async fn handle_peer_ws(socket: WebSocket, state: AppState) { + let (mut ws_tx, mut ws_rx) = socket.split(); + + // First message must be auth + let secret = match state.federation { + Some(ref f) => f.config.shared_secret.clone(), + None => { + tracing::warn!("Federation: WS connection rejected -- federation not configured"); + return; + } }; - 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(); + // Wait for auth message (5 second timeout) + let auth_msg = tokio::time::timeout( + std::time::Duration::from_secs(5), + ws_rx.next(), + ).await; - let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown"); + let peer_id = match auth_msg { + Ok(Some(Ok(Message::Text(text)))) => { + if let Ok(parsed) = serde_json::from_str::(&text) { + let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let msg_secret = parsed.get("secret").and_then(|v| v.as_str()).unwrap_or(""); + let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown"); + if msg_type != "auth" || msg_secret != secret { + tracing::warn!("Federation: WS auth failed from {}", server_id); + return; + } + tracing::info!("Federation: peer {} authenticated via WS", server_id); + server_id.to_string() + } else { + tracing::warn!("Federation: invalid auth JSON"); + return; + } + } + _ => { + tracing::warn!("Federation: no auth message received within timeout"); + return; + } + }; + + // Process incoming messages from the authenticated peer + while let Some(Ok(msg)) = ws_rx.next().await { + if let Message::Text(text) = msg { + let parsed: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + + let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + match msg_type { + "presence" => { + 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 count = fingerprints.len(); + + if let Some(ref federation) = state.federation { + let mut rp = federation.remote_presence.lock().await; + rp.fingerprints = fingerprints.into_iter().collect(); + rp.last_updated = chrono::Utc::now().timestamp(); + rp.connected = true; + } + tracing::debug!("Federation WS: {} announced {} fingerprints", peer_id, count); + + // Send our presence back + if let Some(ref federation) = state.federation { + let fps: Vec = { + let conns = state.connections.lock().await; + conns.keys().cloned().collect() + }; + let reply = serde_json::json!({ + "type": "presence", + "server_id": federation.config.server_id, + "fingerprints": fps, + }); + let _ = ws_tx.send(Message::Text(serde_json::to_string(&reply).unwrap_or_default())).await; + } + } + "forward" => { + let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or(""); + let message_b64 = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); + let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("?"); + + if let Ok(message) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) { + let delivered = state.push_to_client(to, &message).await; + if !delivered { + let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4()); + let _ = state.db.messages.insert(key.as_bytes(), message.as_slice()); + tracing::info!("Federation WS: queued from {} for offline {}", from_server, to); + } else { + tracing::debug!("Federation WS: delivered from {} to {}", from_server, to); + } + } + } + _ => {} + } + } + } + + // Peer disconnected 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); + rp.connected = false; + rp.fingerprints.clear(); } - - (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() + tracing::info!("Federation WS: peer {} disconnected", peer_id); } /// Federation health status. -/// GET /v1/federation/status async fn federation_status( State(state): State, ) -> Json { @@ -130,15 +149,13 @@ async fn federation_status( "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), + "peer_connected": rp.connected, "remote_clients": rp.fingerprints.len(), "last_sync": rp.last_updated, })) } None => { - Json(serde_json::json!({ - "enabled": false, - })) + Json(serde_json::json!({ "enabled": false })) } } } diff --git a/warzone/docs/ARCHITECTURE.md b/warzone/docs/ARCHITECTURE.md index 8ff2522..8a67596 100644 --- a/warzone/docs/ARCHITECTURE.md +++ b/warzone/docs/ARCHITECTURE.md @@ -9,51 +9,14 @@ ```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 + CLI[CLI Client] --> PROTO[warzone-protocol] + TUI[TUI Client] --> PROTO + WEB[Web Client WASM] --> PROTO + PROTO -->|HTTP / WS| SRVA[Server Alpha] + PROTO -->|HTTP / WS| SRVB[Server Bravo] + SRVA <-->|Federation WS| SRVB + SRVA -->|Call Signaling| WZP[WarzonePhone Relay] + SRVB -->|Call Signaling| WZP ``` --- @@ -244,7 +207,7 @@ Offer | Answer | IceCandidate | Hangup | Reject | Ringing | Busy | 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↔Server | WS JSON | JSON frames over persistent federation WS | --- @@ -339,19 +302,13 @@ sequenceDiagram ```mermaid graph LR - subgraph ServerAlpha["Server Alpha"] - CA["Client A
Client B"] - FHA["Federation Handle"] + subgraph Alpha[Server Alpha] + CA[Client A + B] end - - subgraph ServerBravo["Server Bravo"] - CC["Client C
Client D"] - FHB["Federation Handle"] + subgraph Bravo[Server Bravo] + CC[Client C + D] end - - FHA <-->|"Presence sync
(every 5s)"| FHB - FHA -->|"Forward message
(HTTP POST)"| FHB - FHB -->|"Forward message
(HTTP POST)"| FHA + Alpha <-->|Persistent WS\nPresence + Forward| Bravo ``` ### Configuration @@ -365,8 +322,7 @@ Each server has a `federation.json`: "peer": { "id": "bravo", "url": "http://10.0.0.2:7700" - }, - "presence_interval_secs": 5 + } } ``` @@ -374,41 +330,40 @@ Start with: `warzone-server --federation federation.json` ### Presence Sync -Every 5 seconds, each server POSTs its connected fingerprint list to the peer: +On startup each server opens a persistent WebSocket to its peer and authenticates with the shared secret. Presence updates and message forwards flow over this single connection: ``` -POST /v1/federation/presence -X-Federation-Token: SHA-256(secret || body) -{ "server_id": "alpha", "fingerprints": ["aabb...", "ccdd..."], "timestamp": ... } +WS /v1/federation/ws +Auth: {"type":"auth","secret":"HMAC(shared_secret)"} +Presence: {"type":"presence","fingerprints":["aabb...","ccdd..."]} +Forward: {"type":"forward","to":"","message":""} ``` -The receiving server replaces its remote presence set entirely. If 3 intervals pass without a sync, the remote set is cleared (peer assumed down). +The receiving server replaces its remote presence set on each presence frame. If the WebSocket drops, the server auto-reconnects every 3 seconds and re-sends its full presence list. ### 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 } + Note over SA,SB: Persistent WS connection + SA->>SB: {"type":"auth","secret":"..."} + SA->>SB: {"type":"presence","fingerprints":["A","B"]} + SB->>SA: {"type":"presence","fingerprints":["C","D"]} + + Note over SA: Client A sends message to C + SA->>SB: {"type":"forward","to":"C","message":"base64..."} + Note over SB: Deliver to Client C via local WS ``` ### 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 | +| WS disconnected | Auto-reconnect every 3s, messages queue locally | +| Peer restarts | Presence repopulates on WS reconnect | | HMAC mismatch | Request rejected with 401 | --- @@ -632,15 +587,14 @@ sequenceDiagram 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] + Note over SA,SB: Persistent WS between servers + SA->>SB: presence ["A","B"] + SB->>SA: presence ["C","D"] A->>SA: Message for C SA->>SA: Not local, C in remote presence - SA->>SB: POST /federation/forward (HMAC auth) + SA->>SB: forward to C via federation WS SB->>C: Push via local WS - SB->>SA: { "delivered": true } ``` --- From dbf5d136cf1e911ab6b282d4904ad465b5d8e8fc Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sat, 28 Mar 2026 22:59:19 +0400 Subject: [PATCH 03/50] fix: WASM double-X3DH bug, federated aliases, deploy tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WASM fix (critical): - encrypt_key_exchange_with_id was calling x3dh::initiate a second time, generating a new ephemeral key that didn't match the ratchet — receiver always failed to decrypt. Now stores X3DH result from initiate() and reuses it. Added 2 protocol tests confirming the fix + the bug. - Bumped service worker cache to wz-v2 to force browsers to re-fetch. - Disabled wasm-opt for Hetzner builds (libc compat issue). Federation — alias support: - resolve_alias falls back to federation peer if not found locally - register_alias checks peer server before allowing — globally unique aliases - Added resolve_remote_alias() and is_alias_taken_remote() to FederationHandle Federation — key proxy fix: - Remote bundles no longer cached locally (stale cache caused decrypt failures) - Local vs remote determined by device: prefix in keys DB Client fixes: - Self-messaging blocked ("Cannot send messages to yourself") - /peer blocked - last_dm_peer never set to self - /r sends reply inline (switches peer + sends in one command) Deploy tooling: - scripts/build-linux.sh with --ship (build + deploy + destroy) - --update-all, --status, --logs commands - WASM rebuilt on Hetzner VM before server binary - deploy/ directory: systemd service, federation configs, setup script - Journald log cap (50MB, 7-day retention) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../crates/warzone-client/src/tui/commands.rs | 34 +- .../crates/warzone-client/src/tui/network.rs | 8 +- warzone/crates/warzone-protocol/src/x3dh.rs | 137 +++++ .../crates/warzone-server/src/federation.rs | 90 +++- .../warzone-server/src/routes/aliases.rs | 22 +- .../crates/warzone-server/src/routes/keys.rs | 23 +- .../warzone-server/src/routes/messages.rs | 2 +- .../crates/warzone-server/src/routes/web.rs | 2 +- warzone/crates/warzone-wasm/Cargo.toml | 3 + warzone/crates/warzone-wasm/src/lib.rs | 144 +++++- warzone/deploy/federation-kh3rad3ree.json | 8 + warzone/deploy/federation-mequ.json | 8 + warzone/deploy/journald-warzone.conf | 6 + warzone/deploy/setup.sh | 53 ++ warzone/deploy/warzone-server.service | 27 + warzone/scripts/build-linux.sh | 483 ++++++++++++++++++ 16 files changed, 1026 insertions(+), 24 deletions(-) create mode 100644 warzone/deploy/federation-kh3rad3ree.json create mode 100644 warzone/deploy/federation-mequ.json create mode 100644 warzone/deploy/journald-warzone.conf create mode 100755 warzone/deploy/setup.sh create mode 100644 warzone/deploy/warzone-server.service create mode 100755 warzone/scripts/build-linux.sh diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 7298c6f..57a3c9b 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -19,7 +19,7 @@ impl App { db: &LocalDb, client: &ServerClient, ) { - let text = self.input.trim().to_string(); + let mut text = self.input.trim().to_string(); self.input.clear(); self.cursor_pos = 0; @@ -223,15 +223,27 @@ impl App { } return; } - if text == "/r" || text == "/reply" { + if text == "/r" || text == "/reply" || text.starts_with("/r ") || text.starts_with("/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() }); + // If there's a message after /r, mutate text and fall through to send + let reply_msg = if text.starts_with("/reply ") { + text[7..].trim().to_string() + } else if text.starts_with("/r ") { + text[3..].trim().to_string() + } else { + String::new() + }; + if reply_msg.is_empty() { + return; // Just switch peer + } + text = reply_msg; // Fall through to send logic below } 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; } - return; } if text.starts_with("/peer ") || text.starts_with("/p ") { let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() }; @@ -244,6 +256,10 @@ impl App { } else { raw }; + if normfp(&fp) == normfp(&self.our_fp) { + self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + return; + } self.add_message(ChatLine { sender: "system".into(), text: format!("Peer set to {}", fp), @@ -355,6 +371,18 @@ impl App { } }; + // Prevent self-messaging (causes ratchet corruption) + if normfp(&peer) == normfp(&self.our_fp) { + self.add_message(ChatLine { + sender: "system".into(), + text: "Cannot send messages to yourself".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(_) => { diff --git a/warzone/crates/warzone-client/src/tui/network.rs b/warzone/crates/warzone-client/src/tui/network.rs index 917138b..c2dfb42 100644 --- a/warzone/crates/warzone-client/src/tui/network.rs +++ b/warzone/crates/warzone-client/src/tui/network.rs @@ -112,7 +112,9 @@ fn process_wire_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()); + if normfp(&sender_fingerprint) != normfp(our_fp) { + *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(), @@ -159,7 +161,9 @@ fn process_wire_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()); + if normfp(&sender_fingerprint) != normfp(our_fp) { + *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(), diff --git a/warzone/crates/warzone-protocol/src/x3dh.rs b/warzone/crates/warzone-protocol/src/x3dh.rs index 4bc1b39..f66ddbd 100644 --- a/warzone/crates/warzone-protocol/src/x3dh.rs +++ b/warzone/crates/warzone-protocol/src/x3dh.rs @@ -163,4 +163,141 @@ mod tests { assert_eq!(alice_result.shared_secret, bob_secret); } + + /// Simulate the EXACT web client (WASM) flow: + /// 1. Alice: generate identity + SPK, create bundle, register + /// 2. Bob: same + /// 3. Alice: fetch Bob's bundle, WasmSession::initiate (X3DH), encrypt_key_exchange + /// 4. Bob: receive wire bytes, decrypt_wire_message (X3DH respond + ratchet decrypt) + #[test] + fn web_client_x3dh_roundtrip() { + use crate::identity::Seed; + use crate::message::WireMessage; + use crate::ratchet::RatchetState; + + // === Alice === + let alice_seed = Seed::generate(); + let alice_id = alice_seed.derive_identity(); + let alice_pub = alice_id.public_identity(); + let (alice_spk_secret, alice_spk) = generate_signed_pre_key(&alice_id, 1); + let alice_bundle = PreKeyBundle { + identity_key: *alice_pub.signing.as_bytes(), + identity_encryption_key: *alice_pub.encryption.as_bytes(), + signed_pre_key: alice_spk, + one_time_pre_key: None, // web client: no OTPKs + }; + + // === Bob === + let bob_seed = Seed::generate(); + let bob_id = bob_seed.derive_identity(); + let bob_pub = bob_id.public_identity(); + let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1); + let bob_spk_secret_bytes = bob_spk_secret.to_bytes(); + let bob_bundle = PreKeyBundle { + identity_key: *bob_pub.signing.as_bytes(), + identity_encryption_key: *bob_pub.encryption.as_bytes(), + signed_pre_key: bob_spk, + one_time_pre_key: None, + }; + let bob_bundle_bytes = bincode::serialize(&bob_bundle).unwrap(); + + // === Alice sends to Bob (simulating WasmSession::initiate + encrypt_key_exchange_with_id) === + + // Step 1: WasmSession::initiate — X3DH + init ratchet + let x3dh_result = initiate(&alice_id, &bob_bundle).unwrap(); + let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key); + let mut alice_ratchet = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); + + // Step 2: encrypt_key_exchange_with_id — use SAME x3dh_result (NOT re-initiate!) + let encrypted = alice_ratchet.encrypt(b"hello bob").unwrap(); + let wire = WireMessage::KeyExchange { + id: "test-msg-001".to_string(), + sender_fingerprint: alice_pub.fingerprint.to_string(), + sender_identity_encryption_key: *alice_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, + }; + let wire_bytes = bincode::serialize(&wire).unwrap(); + + // === Bob decrypts (simulating decrypt_wire_message) === + let wire_in: WireMessage = bincode::deserialize(&wire_bytes).unwrap(); + match wire_in { + WireMessage::KeyExchange { + sender_identity_encryption_key, + ephemeral_public, + ratchet_message, + .. + } => { + let bob_spk_secret_restored = StaticSecret::from(bob_spk_secret_bytes); + let their_id = PublicKey::from(sender_identity_encryption_key); + let their_eph = PublicKey::from(ephemeral_public); + + let shared = respond( + &bob_id, &bob_spk_secret_restored, None, &their_id, &their_eph, + ).unwrap(); + + let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes); + let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet); + let plaintext = bob_ratchet.decrypt(&ratchet_message).unwrap(); + + assert_eq!(plaintext, b"hello bob"); + } + _ => panic!("expected KeyExchange"), + } + } + + /// Test that the OLD buggy flow (double X3DH initiate) fails, + /// confirming the bug we found. + #[test] + fn double_x3dh_initiate_fails() { + use crate::identity::Seed; + use crate::ratchet::RatchetState; + + let alice_seed = Seed::generate(); + let alice_id = alice_seed.derive_identity(); + let alice_pub = alice_id.public_identity(); + + let bob_seed = Seed::generate(); + let bob_id = bob_seed.derive_identity(); + let bob_pub = bob_id.public_identity(); + let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1); + let bob_spk_secret_bytes = bob_spk_secret.to_bytes(); + let bob_bundle = PreKeyBundle { + identity_key: *bob_pub.signing.as_bytes(), + identity_encryption_key: *bob_pub.encryption.as_bytes(), + signed_pre_key: bob_spk, + one_time_pre_key: None, + }; + + // FIRST X3DH — used for ratchet + let result1 = initiate(&alice_id, &bob_bundle).unwrap(); + let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key); + let mut alice_ratchet = RatchetState::init_alice(result1.shared_secret, their_spk); + let encrypted = alice_ratchet.encrypt(b"test").unwrap(); + + // SECOND X3DH — different ephemeral key (THE BUG) + let result2 = initiate(&alice_id, &bob_bundle).unwrap(); + // result2.ephemeral_public != result1.ephemeral_public + assert_ne!( + result1.ephemeral_public.as_bytes(), + result2.ephemeral_public.as_bytes(), + "two X3DH initiates should produce different ephemeral keys" + ); + + // Bob tries to decrypt using result2's ephemeral (wrong one) + let bob_spk_restored = StaticSecret::from(bob_spk_secret_bytes); + let shared = respond( + &bob_id, &bob_spk_restored, None, + &alice_pub.encryption, &result2.ephemeral_public, + ).unwrap(); + + // The shared secrets DIFFER because different ephemeral keys + assert_ne!(result1.shared_secret, shared, "mismatched ephemeral should produce different shared secret"); + + // Decryption should FAIL + let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes); + let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet); + assert!(bob_ratchet.decrypt(&encrypted).is_err(), "decrypt should fail with wrong shared secret"); + } } diff --git a/warzone/crates/warzone-server/src/federation.rs b/warzone/crates/warzone-server/src/federation.rs index 6566562..19b33cb 100644 --- a/warzone/crates/warzone-server/src/federation.rs +++ b/warzone/crates/warzone-server/src/federation.rs @@ -65,6 +65,8 @@ pub struct FederationHandle { pub remote_presence: Arc>, /// Channel to send messages over the outgoing WS to the peer. pub outgoing: FederationSender, + /// HTTP client for one-shot requests (key fetch, etc.) + pub client: reqwest::Client, } impl FederationHandle { @@ -72,10 +74,15 @@ impl FederationHandle { let remote_presence = Arc::new(Mutex::new(RemotePresence::new( 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, remote_presence, outgoing: Arc::new(Mutex::new(None)), + client, } } @@ -96,6 +103,41 @@ impl FederationHandle { self.send_json(msg).await } + /// Fetch a pre-key bundle from the peer server (HTTP GET fallback). + /// Used when a local key lookup fails and the fingerprint is on the remote. + pub async fn fetch_remote_bundle(&self, fingerprint: &str) -> Option> { + let url = format!("{}/v1/keys/{}", self.config.peer.url, fingerprint); + let resp = self.client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let data: serde_json::Value = resp.json().await.ok()?; + let bundle_b64 = data.get("bundle")?.as_str()?; + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bundle_b64).ok() + } + + /// Resolve an alias on the peer server. + /// Returns Some(fingerprint) if the peer knows this alias. + pub async fn resolve_remote_alias(&self, alias: &str) -> Option { + let url = format!("{}/v1/alias/resolve/{}", self.config.peer.url, alias); + let resp = self.client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let data: serde_json::Value = resp.json().await.ok()?; + // Check for error (alias not found on peer) + if data.get("error").is_some() { + return None; + } + data.get("fingerprint").and_then(|v| v.as_str()).map(String::from) + } + + /// Check if an alias is already taken on the peer server. + /// Returns true if the alias exists on the peer (taken). + pub async fn is_alias_taken_remote(&self, alias: &str) -> bool { + self.resolve_remote_alias(alias).await.is_some() + } + /// Push local presence to peer via the persistent WS. pub async fn push_presence(&self, fingerprints: Vec) -> bool { let msg = serde_json::json!({ @@ -171,10 +213,42 @@ pub async fn outgoing_ws_loop( }; let _ = handle.push_presence(fps).await; - // Spawn task to forward outgoing channel to WS + // Spawn task to forward outgoing channel + periodic ping to WS let send_task = tokio::spawn(async move { - while let Some(msg) = out_rx.recv().await { - if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(msg)).await.is_err() { + let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15)); + loop { + tokio::select! { + msg = out_rx.recv() => { + match msg { + Some(text) => { + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(text)).await.is_err() { + break; + } + } + None => break, + } + } + _ = ping_interval.tick() => { + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.is_err() { + break; + } + } + } + } + }); + + // Spawn task to periodically re-push presence + let presence_handle = handle.clone(); + let presence_conns = state.connections.clone(); + let presence_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(10)); + loop { + interval.tick().await; + let fps: Vec = { + let conns = presence_conns.lock().await; + conns.keys().cloned().collect() + }; + if !presence_handle.push_presence(fps).await { break; } } @@ -182,13 +256,19 @@ pub async fn outgoing_ws_loop( // Read incoming messages from peer while let Some(Ok(msg)) = ws_rx.next().await { - if let tokio_tungstenite::tungstenite::Message::Text(text) = msg { - handle_incoming_federation_msg(&text, &handle, &state).await; + match msg { + tokio_tungstenite::tungstenite::Message::Text(text) => { + handle_incoming_federation_msg(&text, &handle, &state).await; + } + tokio_tungstenite::tungstenite::Message::Pong(_) => {} // keepalive response + tokio_tungstenite::tungstenite::Message::Close(_) => break, + _ => {} } } // Connection lost send_task.abort(); + presence_task.abort(); { let mut guard = handle.outgoing.lock().await; *guard = None; diff --git a/warzone/crates/warzone-server/src/routes/aliases.rs b/warzone/crates/warzone-server/src/routes/aliases.rs index c6faad2..b6dbc7e 100644 --- a/warzone/crates/warzone-server/src/routes/aliases.rs +++ b/warzone/crates/warzone-server/src/routes/aliases.rs @@ -152,6 +152,13 @@ async fn register_alias( delete_alias_record(&state.db.aliases, &existing)?; } + // Check if alias is taken on federation peer (globally unique) + if let Some(ref federation) = state.federation { + if federation.is_alias_taken_remote(&alias).await { + return Ok(Json(serde_json::json!({ "error": "alias already taken on federated server" }))); + } + } + // Remove old alias for this fingerprint (one alias per person) if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string(); @@ -292,7 +299,20 @@ async fn resolve_alias( }))) } } - None => Ok(Json(serde_json::json!({ "error": "alias not found" }))), + None => { + // Try federation peer + if let Some(ref federation) = state.federation { + if let Some(fp) = federation.resolve_remote_alias(&alias).await { + tracing::info!("Alias @{} resolved via federation: {}", alias, fp); + return Ok(Json(serde_json::json!({ + "alias": alias, + "fingerprint": fp, + "federated": true, + }))); + } + } + Ok(Json(serde_json::json!({ "error": "alias not found" }))) + } } } diff --git a/warzone/crates/warzone-server/src/routes/keys.rs b/warzone/crates/warzone-server/src/routes/keys.rs index b65cd03..3bf8359 100644 --- a/warzone/crates/warzone-server/src/routes/keys.rs +++ b/warzone/crates/warzone-server/src/routes/keys.rs @@ -54,7 +54,7 @@ struct RegisterResponse { } async fn register_keys( - _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, Json(req): Json, ) -> Json { @@ -85,9 +85,26 @@ async fn get_bundle( .collect(); tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys); + // Check if this fingerprint registered locally (has a device: entry) + let device_prefix = format!("device:{}:", key); + let is_local = state.db.keys.scan_prefix(device_prefix.as_bytes()).next().is_some(); + + // For remote clients, always proxy from the federation peer (bundles may change) + if !is_local { + if let Some(ref federation) = state.federation { + if let Some(bundle_bytes) = federation.fetch_remote_bundle(&key).await { + tracing::info!("get_bundle: PROXIED from federation peer for {}", key); + return Ok(Json(serde_json::json!({ + "fingerprint": fingerprint, + "bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bundle_bytes), + }))); + } + } + } + match state.db.keys.get(key.as_bytes()) { Ok(Some(data)) => { - tracing::info!("get_bundle: FOUND {} bytes for {}", data.len(), key); + tracing::info!("get_bundle: FOUND {} bytes for {} (local={})", data.len(), key, is_local); Ok(Json(serde_json::json!({ "fingerprint": fingerprint, "bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data), @@ -130,7 +147,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 aa44d48..f1c90ec 100644 --- a/warzone/crates/warzone-server/src/routes/messages.rs +++ b/warzone/crates/warzone-server/src/routes/messages.rs @@ -71,7 +71,7 @@ fn normalize_fp(fp: &str) -> String { } async fn send_message( - _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, Json(req): Json, ) -> AppResult> { diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 8f2894b..d2e21ef 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v1'; +const CACHE = 'wz-v2'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { diff --git a/warzone/crates/warzone-wasm/Cargo.toml b/warzone/crates/warzone-wasm/Cargo.toml index 436a23c..376dc86 100644 --- a/warzone/crates/warzone-wasm/Cargo.toml +++ b/warzone/crates/warzone-wasm/Cargo.toml @@ -3,6 +3,9 @@ name = "warzone-wasm" version.workspace = true edition.workspace = true +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + [lib] crate-type = ["cdylib"] diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs index b655f18..879393d 100644 --- a/warzone/crates/warzone-wasm/src/lib.rs +++ b/warzone/crates/warzone-wasm/src/lib.rs @@ -132,6 +132,9 @@ impl WasmIdentity { #[wasm_bindgen] pub struct WasmSession { ratchet: RatchetState, + /// Stored X3DH result from initiate() — needed for encrypt_key_exchange + x3dh_ephemeral_public: Option<[u8; 32]>, + x3dh_used_otpk_id: Option, } #[wasm_bindgen] @@ -147,6 +150,8 @@ impl WasmSession { let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); Ok(WasmSession { ratchet: RatchetState::init_alice(result.shared_secret, their_spk), + x3dh_ephemeral_public: Some(*result.ephemeral_public.as_bytes()), + x3dh_used_otpk_id: result.used_one_time_pre_key_id, }) } @@ -162,14 +167,14 @@ impl WasmSession { pub fn encrypt_key_exchange_with_id( &mut self, identity: &WasmIdentity, - their_bundle_bytes: &[u8], + _their_bundle_bytes: &[u8], plaintext: &str, msg_id: &str, ) -> Result, JsValue> { - let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - let result = x3dh::initiate(&identity.identity, &bundle) - .map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?; + // Use the stored X3DH result from initiate() — DO NOT re-initiate + // (re-initiating generates a new ephemeral key that doesn't match the ratchet) + let ephemeral_public = self.x3dh_ephemeral_public + .ok_or_else(|| JsValue::from_str("no X3DH result — call initiate() first"))?; let encrypted = self.ratchet.encrypt(plaintext.as_bytes()) .map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?; @@ -178,8 +183,8 @@ impl WasmSession { id: msg_id.to_string(), sender_fingerprint: identity.pub_id.fingerprint.to_string(), sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(), - ephemeral_public: *result.ephemeral_public.as_bytes(), - used_one_time_pre_key_id: result.used_one_time_pre_key_id, + ephemeral_public, + used_one_time_pre_key_id: self.x3dh_used_otpk_id, ratchet_message: encrypted, }; bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string())) @@ -210,7 +215,7 @@ impl WasmSession { .map_err(|e| JsValue::from_str(&e.to_string()))?; let ratchet: RatchetState = bincode::deserialize(&bytes) .map_err(|e| JsValue::from_str(&e.to_string()))?; - Ok(WasmSession { ratchet }) + Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None }) } } @@ -615,3 +620,126 @@ pub fn create_sender_key_from_distribution( let encoded = bincode::serialize(&sender_key).unwrap_or_default(); Ok(hex::encode(encoded)) } + +// Tests live in warzone-protocol to avoid js-sys dependency issues. +// See warzone-protocol/src/x3dh.rs tests for web-client simulation. + +#[cfg(test)] +#[cfg(target_arch = "wasm32")] +mod tests { + use super::*; + + #[test] + fn web_client_to_web_client() { + // === Alice (sender) === + let mut alice = WasmIdentity::new(); + let alice_seed = alice.seed_hex(); + let alice_spk = alice.spk_secret_hex(); + let alice_bundle = alice.bundle_bytes().unwrap(); + + // === Bob (receiver) === + let mut bob = WasmIdentity::new(); + let bob_seed = bob.seed_hex(); + let bob_spk = bob.spk_secret_hex(); + let bob_bundle = bob.bundle_bytes().unwrap(); + + println!("Alice fp: {}", alice.fingerprint()); + println!("Bob fp: {}", bob.fingerprint()); + println!("Alice SPK secret: {}...", &alice_spk[..16]); + println!("Bob SPK secret: {}...", &bob_spk[..16]); + + // === Alice sends to Bob (exactly like the web JS) === + // 1. Alice creates session from Bob's bundle + let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap(); + + // 2. Alice encrypts with key exchange + let wire_bytes = alice_session + .encrypt_key_exchange_with_id(&alice, &bob_bundle, "hello bob", "msg-001") + .unwrap(); + + println!("Wire message size: {} bytes", wire_bytes.len()); + + // === Bob receives and decrypts (exactly like handleIncomingMessage) === + // First try: decrypt_wire_message with null session (handles KeyExchange) + let result = decrypt_wire_message(&bob_seed, &bob_spk, &wire_bytes, None); + + match result { + Ok(json_str) => { + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + println!("Decrypt SUCCESS: {}", json_str); + assert_eq!(parsed["text"].as_str().unwrap(), "hello bob"); + assert!(parsed["new_session"].as_bool().unwrap()); + println!("Session data present: {}", parsed["session_data"].as_str().is_some()); + } + Err(e) => { + panic!("Decrypt FAILED: {:?}", e); + } + } + } + + /// Test that restored session (from base64) can decrypt subsequent messages. + #[test] + fn web_client_session_continuity() { + let mut alice = WasmIdentity::new(); + let mut bob = WasmIdentity::new(); + let bob_seed = bob.seed_hex(); + let bob_spk = bob.spk_secret_hex(); + let bob_bundle = bob.bundle_bytes().unwrap(); + + // Alice sends first message (KeyExchange) + let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap(); + let wire1 = alice_session + .encrypt_key_exchange_with_id(&alice, &bob_bundle, "msg one", "id-1") + .unwrap(); + + // Bob decrypts first message + let result1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire1, None).unwrap(); + let parsed1: serde_json::Value = serde_json::from_str(&result1).unwrap(); + assert_eq!(parsed1["text"].as_str().unwrap(), "msg one"); + let bob_session_data = parsed1["session_data"].as_str().unwrap().to_string(); + + // Alice sends second message (regular Message, not KeyExchange) + let alice_session_data = alice_session.save().unwrap(); + let mut alice_session2 = WasmSession::restore(&alice_session_data).unwrap(); + let wire2 = alice_session2 + .encrypt_with_id(&alice, "msg two", "id-2") + .unwrap(); + + // Bob decrypts second message using saved session + let result2 = decrypt_wire_message(&bob_seed, &bob_spk, &wire2, Some(bob_session_data)).unwrap(); + let parsed2: serde_json::Value = serde_json::from_str(&result2).unwrap(); + assert_eq!(parsed2["text"].as_str().unwrap(), "msg two"); + } + + /// Test bidirectional: Alice sends to Bob, Bob sends to Alice. + #[test] + fn web_client_bidirectional() { + let mut alice = WasmIdentity::new(); + let alice_seed = alice.seed_hex(); + let alice_spk = alice.spk_secret_hex(); + let alice_bundle = alice.bundle_bytes().unwrap(); + + let mut bob = WasmIdentity::new(); + let bob_seed = bob.seed_hex(); + let bob_spk = bob.spk_secret_hex(); + let bob_bundle = bob.bundle_bytes().unwrap(); + + // Alice → Bob + let mut a_session = WasmSession::initiate(&alice, &bob_bundle).unwrap(); + let wire_a2b = a_session + .encrypt_key_exchange_with_id(&alice, &bob_bundle, "hi bob", "a1") + .unwrap(); + let r1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire_a2b, None).unwrap(); + let p1: serde_json::Value = serde_json::from_str(&r1).unwrap(); + assert_eq!(p1["text"].as_str().unwrap(), "hi bob"); + + // Bob → Alice + let mut b_session = WasmSession::initiate(&bob, &alice_bundle).unwrap(); + let wire_b2a = b_session + .encrypt_key_exchange_with_id(&bob, &alice_bundle, "hi alice", "b1") + .unwrap(); + let r2 = decrypt_wire_message(&alice_seed, &alice_spk, &wire_b2a, None).unwrap(); + let p2: serde_json::Value = serde_json::from_str(&r2).unwrap(); + assert_eq!(p2["text"].as_str().unwrap(), "hi alice"); + } +} diff --git a/warzone/deploy/federation-kh3rad3ree.json b/warzone/deploy/federation-kh3rad3ree.json new file mode 100644 index 0000000..407ea0b --- /dev/null +++ b/warzone/deploy/federation-kh3rad3ree.json @@ -0,0 +1,8 @@ +{ + "server_id": "kh3rad3ree", + "shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4", + "peer": { + "id": "mequ", + "url": "http://10.66.66.129:7700" + } +} diff --git a/warzone/deploy/federation-mequ.json b/warzone/deploy/federation-mequ.json new file mode 100644 index 0000000..2048e9c --- /dev/null +++ b/warzone/deploy/federation-mequ.json @@ -0,0 +1,8 @@ +{ + "server_id": "mequ", + "shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4", + "peer": { + "id": "kh3rad3ree", + "url": "http://10.66.66.253:7700" + } +} diff --git a/warzone/deploy/journald-warzone.conf b/warzone/deploy/journald-warzone.conf new file mode 100644 index 0000000..4015a8c --- /dev/null +++ b/warzone/deploy/journald-warzone.conf @@ -0,0 +1,6 @@ +# /etc/systemd/journald.conf.d/warzone.conf +# Cap journal storage to avoid filling disk on mequ +[Journal] +SystemMaxUse=50M +SystemMaxFileSize=10M +MaxRetentionSec=7day diff --git a/warzone/deploy/setup.sh b/warzone/deploy/setup.sh new file mode 100755 index 0000000..de04223 --- /dev/null +++ b/warzone/deploy/setup.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Setup script — run as root on each server. +# Usage: ./setup.sh + +HOSTNAME="${1:-}" +if [ -z "$HOSTNAME" ] || { [ "$HOSTNAME" != "mequ" ] && [ "$HOSTNAME" != "kh3rad3ree" ]; }; then + echo "Usage: $0 " + exit 1 +fi + +echo "=== Setting up featherChat on $HOSTNAME ===" + +# Create warzone user if it doesn't exist +if ! id warzone &>/dev/null; then + echo "[1/4] Creating warzone user..." + useradd -r -m -s /bin/bash warzone +else + echo "[1/4] User warzone already exists" +fi + +# Create data directory +echo "[2/4] Creating directories..." +mkdir -p /home/warzone/data +chown -R warzone:warzone /home/warzone + +# Copy binaries +echo "[3/4] Installing binaries..." +cp warzone-server warzone-client /home/warzone/ +chmod +x /home/warzone/warzone-server /home/warzone/warzone-client +cp "federation-${HOSTNAME}.json" /home/warzone/federation.json +chown warzone:warzone /home/warzone/warzone-server /home/warzone/warzone-client /home/warzone/federation.json + +# Install systemd service + journald log cap +echo "[4/5] Installing systemd service..." +cp warzone-server.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable warzone-server + +echo "[5/5] Capping journal logs (50MB max, 7 day retention)..." +mkdir -p /etc/systemd/journald.conf.d +cp journald-warzone.conf /etc/systemd/journald.conf.d/warzone.conf +systemctl restart systemd-journald +# Vacuum existing logs +journalctl --vacuum-size=50M 2>/dev/null || true + +echo "" +echo "=== Done ===" +echo "Start: systemctl start warzone-server" +echo "Status: systemctl status warzone-server" +echo "Logs: journalctl -u warzone-server -f" +echo "Stop: systemctl stop warzone-server" diff --git a/warzone/deploy/warzone-server.service b/warzone/deploy/warzone-server.service new file mode 100644 index 0000000..534e5a0 --- /dev/null +++ b/warzone/deploy/warzone-server.service @@ -0,0 +1,27 @@ +[Unit] +Description=Warzone Messenger Server (featherChat) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=warzone +Group=warzone +WorkingDirectory=/home/warzone +ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json +Restart=always +RestartSec=3 +LimitNOFILE=65536 + +# Security hardening +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/warzone/data +PrivateTmp=yes + +# Environment — warn-only to minimize disk usage (set to info for debugging) +Environment=RUST_LOG=warn,warzone_server::federation=info + +[Install] +WantedBy=multi-user.target diff --git a/warzone/scripts/build-linux.sh b/warzone/scripts/build-linux.sh new file mode 100755 index 0000000..7b440ff --- /dev/null +++ b/warzone/scripts/build-linux.sh @@ -0,0 +1,483 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build featherChat Linux x86_64 release binaries using a Hetzner Cloud VPS. +# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered. +# +# Usage: +# ./scripts/build-linux.sh --prepare Create VM, install deps, upload source +# ./scripts/build-linux.sh --build Build release binaries on the VM +# ./scripts/build-linux.sh --transfer Download binaries from VM to local +# ./scripts/build-linux.sh --destroy Delete the VM +# ./scripts/build-linux.sh --all Run prepare + build + transfer (no destroy) +# ./scripts/build-linux.sh --upload Re-upload source to existing VM +# +# The VM persists between steps so you can iterate on build errors. +# Reuses the same WZP builder VM if it already exists. + +VM_NAME="fc-builder" +SSH_KEY_NAME="wz" +SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp" +SERVER_TYPE="cx33" +IMAGE="debian-12" +REMOTE_USER="root" +OUTPUT_DIR="target/linux-x86_64" +PROJECT_DIR="/Users/manwe/CascadeProjects/featherChat/warzone" + +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10" + +# Binaries to build +BINS="warzone-server warzone-client" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +get_vm_ip() { + local ip + ip=$(hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' ') + if [ -z "$ip" ]; then + echo "ERROR: No VM '$VM_NAME' found. Run --prepare first." >&2 + exit 1 + fi + echo "$ip" +} + +ssh_cmd() { + local ip + ip=$(get_vm_ip) + ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@" +} + +scp_to() { + local ip + ip=$(get_vm_ip) + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$@" "$REMOTE_USER@$ip:/root/" 2>/dev/null +} + +scp_from() { + local ip + ip=$(get_vm_ip) + # args: remote_path local_path + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2" 2>/dev/null +} + +# --------------------------------------------------------------------------- +# --prepare: Create VM, install deps, upload source +# --------------------------------------------------------------------------- + +do_prepare() { + # Check if VM already exists + local existing + existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true) + if [ -n "$existing" ]; then + echo "VM already exists: $existing" + echo "Reusing it. Uploading fresh source..." + do_upload + return + fi + + echo "[1/5] Creating Hetzner VM: $VM_NAME ($SERVER_TYPE, $IMAGE)..." + hcloud server create \ + --name "$VM_NAME" \ + --type "$SERVER_TYPE" \ + --image "$IMAGE" \ + --ssh-key "$SSH_KEY_NAME" \ + --location fsn1 \ + --quiet + + local ip + ip=$(get_vm_ip) + echo " VM: $VM_NAME @ $ip" + + # Wait for SSH + echo "[2/5] Waiting for SSH..." + for i in $(seq 1 30); do + if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then + break + fi + sleep 2 + done + + # Install build dependencies + echo "[3/5] Installing build dependencies..." + ssh_cmd "apt-get update -qq && apt-get install -y -qq \ + build-essential \ + pkg-config \ + libssl-dev \ + curl \ + git \ + > /dev/null 2>&1" + + # Install Rust + wasm-pack + echo "[4/5] Installing Rust + wasm-pack..." + ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1" + ssh_cmd "source ~/.cargo/env && rustup target add wasm32-unknown-unknown > /dev/null 2>&1" + ssh_cmd "source ~/.cargo/env && cargo install wasm-pack > /dev/null 2>&1 || true" + + # Upload source + echo "[5/5] Uploading source code..." + do_upload + + echo "" + echo "=== VM Ready ===" + echo "IP: $ip" + echo "SSH: ssh -i $SSH_KEY_PATH root@$ip" + echo "" + echo "Next: ./scripts/build-linux.sh --build" +} + +do_upload() { + echo " Creating source tarball..." + + # Create tarball excluding build artifacts and non-essential files + tar czf /tmp/fc-src.tar.gz \ + --exclude='target' \ + --exclude='.git' \ + --exclude='.claude' \ + --exclude='warzone-phone' \ + --exclude='notes' \ + -C "$PROJECT_DIR" . 2>/dev/null + + local ip + ip=$(get_vm_ip) + local size + size=$(du -h /tmp/fc-src.tar.gz | cut -f1) + echo " Uploading $size to VM..." + scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-src.tar.gz "$REMOTE_USER@$ip:/root/fc-src.tar.gz" 2>/dev/null + ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-src.tar.gz -C /root/featherChat" 2>/dev/null + rm -f /tmp/fc-src.tar.gz + echo " Source uploaded." +} + +# --------------------------------------------------------------------------- +# --build: Build release binaries on the VM +# --------------------------------------------------------------------------- + +do_build() { + local ip + ip=$(get_vm_ip) + echo "=== Building on $ip ===" + + local bin_args="" + for bin in $BINS; do + bin_args="$bin_args --bin $bin" + done + + echo "[1/3] Building WASM (warzone-wasm)..." + ssh_cmd "source ~/.cargo/env && cd /root/featherChat && wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg 2>&1" | tail -5 + + echo "" + echo "[2/3] Building: $BINS" + ssh_cmd "source ~/.cargo/env && cd /root/featherChat && cargo build --release $bin_args 2>&1" + + echo "" + echo "[3/3] Verifying binaries..." + for bin in $BINS; do + ssh_cmd "ls -lh /root/featherChat/target/release/$bin" 2>/dev/null + done + + echo "" + echo "=== Build Complete ===" + echo "Next: ./scripts/build-linux.sh --transfer" +} + +# --------------------------------------------------------------------------- +# --transfer: Download binaries from VM to local +# --------------------------------------------------------------------------- + +do_transfer() { + local ip + ip=$(get_vm_ip) + echo "=== Downloading binaries from $ip ===" + + mkdir -p "$OUTPUT_DIR" + + for bin in $BINS; do + echo " $bin..." + scp_from "/root/featherChat/target/release/$bin" "$OUTPUT_DIR/$bin" + done + + # Also grab the embedded web client HTML if it exists + if ssh_cmd "test -f /root/featherChat/target/release/warzone-server" 2>/dev/null; then + echo " federation.example.json..." + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:/root/featherChat/federation.example.json" "$OUTPUT_DIR/federation.example.json" 2>/dev/null || true + fi + + echo "" + echo "=== Transfer Complete ===" + ls -lh "$OUTPUT_DIR"/warzone-* + echo "" + echo "Deploy with:" + echo " scp $OUTPUT_DIR/warzone-server $OUTPUT_DIR/warzone-client user@mequ:~/warzone/" + echo "" + echo "Run on server:" + echo " ./warzone-server --bind 0.0.0.0:7700" + echo " ./warzone-server --bind 0.0.0.0:7700 --federation federation.json" +} + +# --------------------------------------------------------------------------- +# --destroy: Delete the VM +# --------------------------------------------------------------------------- + +do_destroy() { + local existing + existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true) + if [ -z "$existing" ]; then + echo "No VM '$VM_NAME' to destroy." + return + fi + echo "Deleting VM: $VM_NAME" + hcloud server delete "$VM_NAME" + echo "Done." +} + +# --------------------------------------------------------------------------- +# --deploy: Transfer + deploy to production server +# --------------------------------------------------------------------------- + +do_deploy() { + local deploy_host="${2:-}" + if [ -z "$deploy_host" ]; then + echo "Usage: $0 --deploy [--federation ]" + echo "" + echo "Example:" + echo " $0 --deploy root@mequ.example.com" + echo " $0 --deploy root@mequ.example.com --federation federation.json" + exit 1 + fi + + echo "=== Deploying to $deploy_host ===" + + # Ensure binaries exist locally + if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then + echo "ERROR: No binaries in $OUTPUT_DIR. Run --build and --transfer first." + exit 1 + fi + + echo "[1/3] Uploading binaries..." + scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$deploy_host:~/warzone/" + + # Upload federation config if specified + local fed_arg="" + if [ "${3:-}" = "--federation" ] && [ -n "${4:-}" ]; then + echo "[2/3] Uploading federation config..." + scp "$4" "$deploy_host:~/warzone/federation.json" + fed_arg="--federation ~/warzone/federation.json" + else + echo "[2/3] No federation config (standalone mode)" + fi + + echo "[3/3] Restarting server..." + ssh "$deploy_host" "pkill warzone-server || true; sleep 1; cd ~/warzone && nohup ./warzone-server --bind 0.0.0.0:7700 $fed_arg > server.log 2>&1 &" + + echo "" + echo "=== Deployed ===" + echo "Server running at $deploy_host:7700" + echo "Logs: ssh $deploy_host 'tail -f ~/warzone/server.log'" +} + +# --------------------------------------------------------------------------- +# Production servers +# --------------------------------------------------------------------------- + +PROD_SERVERS=( + "root@mequ" + "root@kh3rad3ree" +) +PROD_SERVICE="warzone-server" +PROD_BIN_DIR="/home/warzone" + +# --------------------------------------------------------------------------- +# --update : Stop service, upload binaries, restart +# --------------------------------------------------------------------------- + +do_update() { + local host="${1:-}" + if [ -z "$host" ]; then + echo "Usage: $0 --update " + echo " or: $0 --update-all" + exit 1 + fi + + if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then + echo "ERROR: No binaries in $OUTPUT_DIR. Run --all first." + exit 1 + fi + + echo "=== Updating $host ===" + + echo "[1/4] Stopping service..." + ssh "$host" "systemctl stop $PROD_SERVICE 2>/dev/null || true" + + echo "[2/4] Uploading binaries..." + scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$host:$PROD_BIN_DIR/" + ssh "$host" "chmod +x $PROD_BIN_DIR/warzone-server $PROD_BIN_DIR/warzone-client" + + echo "[3/4] Starting service..." + ssh "$host" "systemctl start $PROD_SERVICE" + + echo "[4/4] Verifying..." + sleep 1 + local status + status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || true) + if [ "$status" = "active" ]; then + echo " $host: $PROD_SERVICE is running" + else + echo " WARNING: $host: $PROD_SERVICE status = $status" + echo " Check logs: ssh $host 'journalctl -u $PROD_SERVICE -n 20'" + fi + + echo "" +} + +# --------------------------------------------------------------------------- +# --update-all: Update all production servers +# --------------------------------------------------------------------------- + +do_update_all() { + if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then + echo "ERROR: No binaries in $OUTPUT_DIR. Run --all first." + exit 1 + fi + + echo "=== Updating all production servers ===" + echo "" + for host in "${PROD_SERVERS[@]}"; do + do_update "$host" + done + echo "=== All servers updated ===" +} + +# --------------------------------------------------------------------------- +# --status: Check service status on all production servers +# --------------------------------------------------------------------------- + +do_status() { + echo "=== Production server status ===" + for host in "${PROD_SERVERS[@]}"; do + local status + status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || echo "unreachable") + local uptime + uptime=$(ssh "$host" "systemctl show $PROD_SERVICE --property=ActiveEnterTimestamp --value 2>/dev/null" || echo "?") + printf " %-20s %s (since %s)\n" "$host" "$status" "$uptime" + done + echo "" + + # Check federation + for host in "${PROD_SERVERS[@]}"; do + local addr + addr=$(echo "$host" | cut -d@ -f2) + echo " Federation ($addr):" + curl -s "http://$addr:7700/v1/federation/status" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (unreachable)" + echo "" + done +} + +# --------------------------------------------------------------------------- +# --logs : Tail logs +# --------------------------------------------------------------------------- + +do_logs() { + local host="${1:-${PROD_SERVERS[0]}}" + echo "=== Logs from $host ===" + ssh "$host" "journalctl -u $PROD_SERVICE -f --no-pager" +} + +# --------------------------------------------------------------------------- +# --ship: Build + deploy to all servers + destroy VM (full pipeline) +# --------------------------------------------------------------------------- + +do_ship() { + echo "========================================" + echo " SHIPPING featherChat to production" + echo "========================================" + echo "" + + do_prepare + echo "" + do_build + echo "" + do_transfer + echo "" + do_update_all + echo "" + do_destroy + echo "" + do_status + echo "" + echo "========================================" + echo " SHIP COMPLETE" + echo "========================================" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +case "${1:-}" in + --prepare) + do_prepare + ;; + --build) + do_build + ;; + --transfer) + do_transfer + ;; + --destroy) + do_destroy + ;; + --deploy) + do_deploy "$@" + ;; + --update) + do_update "${2:-}" + ;; + --update-all) + do_update_all + ;; + --status) + do_status + ;; + --logs) + do_logs "${2:-}" + ;; + --all) + do_prepare + do_build + do_transfer + echo "" + echo "VM is still running. Destroy with: ./scripts/build-linux.sh --destroy" + ;; + --ship) + do_ship + ;; + --upload) + do_upload + ;; + *) + echo "Usage: $0 [args]" + echo "" + echo "One command:" + echo " --ship Build + deploy to all servers + destroy VM" + echo "" + echo "Build (Hetzner VM):" + echo " --prepare Create VM, install deps, upload source" + echo " --build Build release binaries" + echo " --transfer Download binaries to $OUTPUT_DIR" + echo " --destroy Delete the build VM" + echo " --all prepare + build + transfer (VM persists)" + echo " --upload Re-upload source to existing VM" + echo "" + echo "Deploy:" + echo " --update Stop service, upload binaries, restart" + echo " --update-all Update mequ + kh3rad3ree" + echo " --deploy First-time deploy (upload + start)" + echo "" + echo "Monitor:" + echo " --status Check service status on all servers" + echo " --logs [user@host] Tail server logs (default: mequ)" + exit 1 + ;; +esac From 7b72f7cba580130d24bd5a044c6afa0cad129e2f Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 07:31:54 +0400 Subject: [PATCH 04/50] feat: friend list, bot API, ETH addressing, deep links, docs overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 — New features: - E2E encrypted friend list: server stores opaque blob (POST/GET /v1/friends), protocol-level encrypt/decrypt with HKDF-derived key, 4 tests - Telegram Bot API compatibility: /bot/register, /bot/:token/getUpdates, sendMessage, getMe — TG-style Update objects with proper message mapping - ETH address resolution: GET /v1/resolve/:address (0x.../alias/@.../fp), bidirectional ETH↔fp mapping stored on key registration - Seed recovery: /seed command in TUI + web client - URL deep links: /message/@alias, /message/0xABC, /group/#ops - Group members with online status in GET /groups/:name/members Tier 2 — UX polish: - TUI: /friend, /friend , /unfriend with presence checking - Web: friend commands, showGroupMembers() on group join - Web: ETH address in header, clickable addresses (click→peer or copy) - Bot: full WireMessage→TG Update mapping (encrypted base64, CallSignal, FileHeader, bot_message JSON) Documentation: - USAGE.md rewritten: complete user guide with all commands - SERVER.md rewritten: full admin guide with all 50+ endpoints - CLIENT.md rewritten: architecture, commands, keyboard, storage - LLM_HELP.md created: 1083-word token-optimized reference for helper LLM Co-Authored-By: Claude Opus 4.6 (1M context) --- .../crates/warzone-client/src/tui/commands.rs | 126 +++ .../crates/warzone-protocol/src/friends.rs | 113 +++ warzone/crates/warzone-protocol/src/lib.rs | 1 + warzone/crates/warzone-server/src/db.rs | 6 + .../crates/warzone-server/src/routes/bot.rs | 390 +++++++++ .../warzone-server/src/routes/friends.rs | 54 ++ .../warzone-server/src/routes/groups.rs | 9 +- .../crates/warzone-server/src/routes/keys.rs | 12 + .../crates/warzone-server/src/routes/mod.rs | 6 + .../warzone-server/src/routes/resolve.rs | 102 +++ .../crates/warzone-server/src/routes/web.rs | 143 +++- warzone/docs/CLIENT.md | 767 ++++++++++-------- warzone/docs/LLM_HELP.md | 159 ++++ warzone/docs/SERVER.md | 656 ++++++++------- warzone/docs/USAGE.md | 660 ++++++--------- 15 files changed, 2181 insertions(+), 1023 deletions(-) create mode 100644 warzone/crates/warzone-protocol/src/friends.rs create mode 100644 warzone/crates/warzone-server/src/routes/bot.rs create mode 100644 warzone/crates/warzone-server/src/routes/friends.rs create mode 100644 warzone/crates/warzone-server/src/routes/resolve.rs create mode 100644 warzone/docs/LLM_HELP.md diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 57a3c9b..9236200 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -8,6 +8,7 @@ use x25519_dalek::PublicKey; use crate::net::ServerClient; use crate::storage::LocalDb; +use base64::Engine; use chrono::Local; use super::types::{App, ChatLine, ReceiptStatus, normfp}; @@ -48,6 +49,7 @@ impl App { " /help, /? Show this help", " /info Show your fingerprint", " /eth Show Ethereum address", + " /seed Show recovery mnemonic (24 words)", " /peer , /p Set DM peer by fingerprint", " /peer @alias Set DM peer by alias", " /reply, /r Reply to last DM sender", @@ -57,6 +59,9 @@ impl App { " /alias Register an alias for yourself", " /aliases List all registered aliases", " /unalias Remove your alias", + " /friend List friends with online status", + " /friend
Add a friend", + " /unfriend
Remove a friend", " /devices List your active device sessions", " /kick Kick a specific device session", " /g Switch to group (auto-join)", @@ -173,6 +178,127 @@ impl App { } return; } + if text == "/seed" { + if let Ok(seed) = crate::keystore::load_seed_raw() { + let mnemonic = warzone_protocol::identity::Seed::from_bytes(seed).to_mnemonic(); + self.add_message(ChatLine { sender: "system".into(), text: "Your recovery seed (keep secret!):".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: mnemonic, is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: "Failed to load seed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + return; + } + if text == "/friend" || text == "/friends" { + // Fetch encrypted friend list from server, decrypt locally + let url = format!("{}/v1/friends", client.base_url); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + match data.get("data").and_then(|v| v.as_str()) { + Some(blob_b64) => { + if let Ok(seed) = crate::keystore::load_seed_raw() { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + match warzone_protocol::friends::FriendList::decrypt(&seed, &blob) { + Ok(list) => { + if list.friends.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: format!("Friends ({}):", list.friends.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + for f in &list.friends { + // Check presence + let presence_url = format!("{}/v1/presence/{}", client.base_url, normfp(&f.address)); + let online = match client.client.get(&presence_url).send().await { + Ok(r) => r.json::().await.ok() + .and_then(|d| d.get("online").and_then(|v| v.as_bool())) + .unwrap_or(false), + Err(_) => false, + }; + let status = if online { "online" } else { "offline" }; + let label = match &f.alias { + Some(a) => format!(" @{} ({}) — {}", a, &f.address[..f.address.len().min(16)], status), + None => format!(" {} — {}", &f.address[..f.address.len().min(16)], status), + }; + 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!("Failed to decrypt friend list: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + } + } + } + _ => { + self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".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.starts_with("/friend ") { + let addr = text[8..].trim().to_string(); + if addr.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "Usage: /friend
".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + return; + } + if let Ok(seed) = crate::keystore::load_seed_raw() { + // Fetch existing list + let url = format!("{}/v1/friends", client.base_url); + let mut list = match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(blob_b64) = data.get("data").and_then(|v| v.as_str()) { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + warzone_protocol::friends::FriendList::decrypt(&seed, &blob).unwrap_or_default() + } else { + warzone_protocol::friends::FriendList::new() + } + } else { + warzone_protocol::friends::FriendList::new() + } + } + Err(_) => warzone_protocol::friends::FriendList::new(), + }; + list.add(&addr, None); + let encrypted = list.encrypt(&seed); + let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); + let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await; + self.add_message(ChatLine { sender: "system".into(), text: format!("Added {} to friends", addr), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + return; + } + if text.starts_with("/unfriend ") { + let addr = text[10..].trim().to_string(); + if addr.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "Usage: /unfriend
".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + return; + } + if let Ok(seed) = crate::keystore::load_seed_raw() { + let url = format!("{}/v1/friends", client.base_url); + let mut list = match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(blob_b64) = data.get("data").and_then(|v| v.as_str()) { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + warzone_protocol::friends::FriendList::decrypt(&seed, &blob).unwrap_or_default() + } else { + warzone_protocol::friends::FriendList::new() + } + } else { + warzone_protocol::friends::FriendList::new() + } + } + Err(_) => warzone_protocol::friends::FriendList::new(), + }; + list.remove(&addr); + let encrypted = list.encrypt(&seed); + let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); + let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await; + self.add_message(ChatLine { sender: "system".into(), text: format!("Removed {} from friends", addr), 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) diff --git a/warzone/crates/warzone-protocol/src/friends.rs b/warzone/crates/warzone-protocol/src/friends.rs new file mode 100644 index 0000000..1522f6b --- /dev/null +++ b/warzone/crates/warzone-protocol/src/friends.rs @@ -0,0 +1,113 @@ +//! Encrypted friend list — stored on server as opaque blob. + +use serde::{Deserialize, Serialize}; +use crate::crypto::{aead_encrypt, aead_decrypt, hkdf_derive}; + +/// A friend entry. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Friend { + /// ETH address or fingerprint + pub address: String, + /// Optional display name / alias + pub alias: Option, + /// When this friend was added (unix timestamp) + pub added_at: i64, +} + +/// The full friend list (plaintext, before encryption). +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct FriendList { + pub friends: Vec, +} + +impl FriendList { + pub fn new() -> Self { + FriendList { friends: vec![] } + } + + pub fn add(&mut self, address: &str, alias: Option<&str>) { + // Don't add duplicates + if self.friends.iter().any(|f| f.address == address) { + return; + } + self.friends.push(Friend { + address: address.to_string(), + alias: alias.map(String::from), + added_at: chrono::Utc::now().timestamp(), + }); + } + + pub fn remove(&mut self, address: &str) { + self.friends.retain(|f| f.address != address); + } + + /// Encrypt the friend list for server storage. + /// Key is derived from the user's seed: HKDF(seed, info="warzone-friends"). + pub fn encrypt(&self, seed: &[u8; 32]) -> Vec { + let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32); + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + let plaintext = serde_json::to_vec(self).unwrap_or_default(); + aead_encrypt(&key, &plaintext, b"warzone-friends-aad") + } + + /// Decrypt a friend list blob from the server. + pub fn decrypt(seed: &[u8; 32], ciphertext: &[u8]) -> Result { + let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32); + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + let plaintext = aead_decrypt(&key, ciphertext, b"warzone-friends-aad")?; + serde_json::from_slice(&plaintext) + .map_err(|e| crate::errors::ProtocolError::RatchetError(format!("friend list json: {}", e))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_roundtrip() { + let seed = [42u8; 32]; + let mut list = FriendList::new(); + list.add("0x1234abcd", Some("alice")); + list.add("0xdeadbeef", None); + + let encrypted = list.encrypt(&seed); + let decrypted = FriendList::decrypt(&seed, &encrypted).unwrap(); + + assert_eq!(decrypted.friends.len(), 2); + assert_eq!(decrypted.friends[0].address, "0x1234abcd"); + assert_eq!(decrypted.friends[0].alias.as_deref(), Some("alice")); + assert_eq!(decrypted.friends[1].address, "0xdeadbeef"); + } + + #[test] + fn wrong_seed_fails() { + let seed = [42u8; 32]; + let wrong_seed = [99u8; 32]; + let mut list = FriendList::new(); + list.add("0x1234", None); + + let encrypted = list.encrypt(&seed); + assert!(FriendList::decrypt(&wrong_seed, &encrypted).is_err()); + } + + #[test] + fn no_duplicate_add() { + let mut list = FriendList::new(); + list.add("0x1234", None); + list.add("0x1234", Some("alice")); + assert_eq!(list.friends.len(), 1); + } + + #[test] + fn remove_works() { + let mut list = FriendList::new(); + list.add("0x1234", None); + list.add("0x5678", None); + list.remove("0x1234"); + assert_eq!(list.friends.len(), 1); + assert_eq!(list.friends[0].address, "0x5678"); + } +} diff --git a/warzone/crates/warzone-protocol/src/lib.rs b/warzone/crates/warzone-protocol/src/lib.rs index 71b8ce2..1cb9654 100644 --- a/warzone/crates/warzone-protocol/src/lib.rs +++ b/warzone/crates/warzone-protocol/src/lib.rs @@ -12,3 +12,4 @@ pub mod store; pub mod history; pub mod sender_keys; pub mod ethereum; +pub mod friends; diff --git a/warzone/crates/warzone-server/src/db.rs b/warzone/crates/warzone-server/src/db.rs index 5cbf538..1db7b4d 100644 --- a/warzone/crates/warzone-server/src/db.rs +++ b/warzone/crates/warzone-server/src/db.rs @@ -8,6 +8,8 @@ pub struct Database { pub tokens: sled::Tree, pub calls: sled::Tree, pub missed_calls: sled::Tree, + pub friends: sled::Tree, + pub eth_addresses: sled::Tree, _db: sled::Db, } @@ -21,6 +23,8 @@ impl Database { let tokens = db.open_tree("tokens")?; let calls = db.open_tree("calls")?; let missed_calls = db.open_tree("missed_calls")?; + let friends = db.open_tree("friends")?; + let eth_addresses = db.open_tree("eth_addresses")?; Ok(Database { keys, messages, @@ -29,6 +33,8 @@ impl Database { tokens, calls, missed_calls, + friends, + eth_addresses, _db: db, }) } diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs new file mode 100644 index 0000000..3578986 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -0,0 +1,390 @@ +//! Telegram Bot API compatibility layer. +//! +//! Bots register with a fingerprint and get a token. +//! They use `/bot/getUpdates` and `/bot/sendMessage` +//! to communicate with featherChat users. + +use axum::{ + extract::{Path, State}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use base64::Engine; + +use crate::errors::AppResult; +use crate::state::AppState; + +/// Build the bot API routes (nested under `/v1`). +pub fn routes() -> Router { + Router::new() + .route("/bot/register", post(register_bot)) + .route("/bot/:token/getUpdates", post(get_updates)) + .route("/bot/:token/sendMessage", post(send_message)) + .route("/bot/:token/getMe", get(get_me)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Validate a bot token against the `tokens` sled tree. +/// Returns the stored bot info JSON if the token is valid. +fn validate_bot_token(state: &AppState, token: &str) -> Option { + let key = format!("bot:{}", token); + let ivec = state.db.tokens.get(key.as_bytes()).ok()??; + serde_json::from_slice(&ivec).ok() +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct RegisterBotRequest { + name: String, + fingerprint: String, +} + +/// Register a bot and receive a token. +/// +/// `POST /v1/bot/register` +/// +/// ```json +/// { "name": "mybot", "fingerprint": "aabbccdd..." } +/// ``` +async fn register_bot( + State(state): State, + Json(req): Json, +) -> AppResult> { + let fp = req + .fingerprint + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + + let random_bytes: [u8; 16] = rand::random(); + let token = format!( + "{}:{}", + &fp[..fp.len().min(16)], + hex::encode(random_bytes), + ); + + let bot_info = serde_json::json!({ + "name": req.name, + "fingerprint": fp, + "token": token, + "created_at": chrono::Utc::now().timestamp(), + }); + + // Store bot info keyed by token. + let key = format!("bot:{}", token); + state + .db + .tokens + .insert(key.as_bytes(), serde_json::to_vec(&bot_info)?.as_slice())?; + + // Reverse lookup: fingerprint -> token. + let fp_key = format!("bot_fp:{}", fp); + state + .db + .tokens + .insert(fp_key.as_bytes(), token.as_bytes())?; + + tracing::info!( + "Bot registered: {} ({}) token={}...", + req.name, + fp, + &token[..token.len().min(20)] + ); + + Ok(Json(serde_json::json!({ + "ok": true, + "result": { + "token": token, + "name": req.name, + "fingerprint": fp, + } + }))) +} + +/// `GET /bot/:token/getMe` -- returns bot info (Telegram-compatible shape). +async fn get_me( + State(state): State, + Path(token): Path, +) -> Json { + match validate_bot_token(&state, &token) { + Some(info) => Json(serde_json::json!({ + "ok": true, + "result": { + "id": info["fingerprint"], + "is_bot": true, + "first_name": info["name"], + "username": info["name"], + } + })), + None => Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })), + } +} + +/// `POST /bot/:token/getUpdates` -- long-poll for messages sent to this bot. +/// +/// Reads from the `queue::*` key range in the messages sled tree, +/// converts each entry into a Telegram-style `Update` object, and deletes +/// consumed entries. +async fn get_updates( + State(state): State, + Path(token): Path, + Json(params): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(info) => info, + None => { + return Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })) + } + }; + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or(""); + let timeout = params + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let prefix = format!("queue:{}", bot_fp); + let mut updates = Vec::new(); + let mut keys_to_delete = Vec::new(); + let mut update_id = 1u64; + + for item in state.db.messages.scan_prefix(prefix.as_bytes()) { + let (key, value) = match item { + Ok(pair) => pair, + Err(_) => continue, + }; + + if let Ok(wire) = + bincode::deserialize::(&value) + { + match wire { + warzone_protocol::message::WireMessage::Message { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value); + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": id, + "from": { + "id": &sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": &sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": null, + "raw_encrypted": raw_b64, + } + })); + update_id += 1; + } + warzone_protocol::message::WireMessage::KeyExchange { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value); + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": id, + "from": { + "id": &sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": &sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": null, + "raw_encrypted": raw_b64, + } + })); + update_id += 1; + } + warzone_protocol::message::WireMessage::CallSignal { + id, + sender_fingerprint, + signal_type, + payload, + .. + } => { + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": id, + "from": { + "id": &sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": &sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": format!("/call_{:?}", signal_type), + "call_signal": { + "type": format!("{:?}", signal_type), + "payload": payload, + }, + } + })); + update_id += 1; + } + warzone_protocol::message::WireMessage::FileHeader { + id, + sender_fingerprint, + filename, + file_size, + .. + } => { + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": id, + "from": { + "id": &sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": &sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "document": { + "file_name": filename, + "file_size": file_size, + }, + } + })); + update_id += 1; + } + // Skip receipts — don't deliver as updates. + warzone_protocol::message::WireMessage::Receipt { .. } => {} + // Skip other variants (FileChunk, GroupSenderKey, SenderKeyDistribution). + _ => {} + } + } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { + // Try plaintext bot message (from other bots via sendMessage). + if bot_msg.get("type").and_then(|v| v.as_str()) == Some("bot_message") { + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), + "is_bot": true, + }, + "chat": { + "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), + "type": "private", + }, + "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), + "text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), + } + })); + update_id += 1; + } + } + + keys_to_delete.push(key); + } + + // Remove consumed messages. + for key in &keys_to_delete { + let _ = state.db.messages.remove(key); + } + + // Simplified long-poll: if the queue was empty, wait up to `timeout` seconds + // (capped at 5 s) before returning, giving new messages a chance to arrive. + if updates.is_empty() && timeout > 0 { + let wait = std::cmp::min(timeout, 5); + tokio::time::sleep(std::time::Duration::from_secs(wait)).await; + } + + Json(serde_json::json!({ + "ok": true, + "result": updates, + })) +} + +#[derive(Deserialize)] +struct SendMessageRequest { + chat_id: String, + text: String, +} + +/// `POST /bot/:token/sendMessage` -- send a plaintext message to a user. +/// +/// In v1, bot messages are **not** E2E-encrypted; they are delivered as +/// plain JSON envelopes through the normal routing layer. +async fn send_message( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(info) => info, + None => { + return Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })) + } + }; + + let to_fp = req + .chat_id + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); + + let msg_id = uuid::Uuid::new_v4().to_string(); + let bot_msg = serde_json::json!({ + "type": "bot_message", + "id": msg_id, + "from": bot_fp, + "text": req.text, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default(); + + let delivered = state.deliver_or_queue(&to_fp, &msg_bytes).await; + + Json(serde_json::json!({ + "ok": true, + "result": { + "message_id": msg_id, + "chat": { "id": to_fp, "type": "private" }, + "text": req.text, + "date": chrono::Utc::now().timestamp(), + "delivered": delivered, + } + })) +} diff --git a/warzone/crates/warzone-server/src/routes/friends.rs b/warzone/crates/warzone-server/src/routes/friends.rs new file mode 100644 index 0000000..dbb158b --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/friends.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::State, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use crate::auth_middleware::AuthFingerprint; +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/friends", get(get_friends)) + .route("/friends", post(save_friends)) +} + +/// Get the encrypted friend list blob for the authenticated user. +async fn get_friends( + auth: AuthFingerprint, + State(state): State, +) -> AppResult> { + match state.db.friends.get(auth.fingerprint.as_bytes())? { + Some(data) => { + let blob = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data); + Ok(Json(serde_json::json!({ + "fingerprint": auth.fingerprint, + "data": blob, + }))) + } + None => Ok(Json(serde_json::json!({ + "fingerprint": auth.fingerprint, + "data": null, + }))), + } +} + +#[derive(Deserialize)] +struct SaveFriendsRequest { + data: String, // base64-encoded encrypted blob +} + +/// Save the encrypted friend list blob. +async fn save_friends( + auth: AuthFingerprint, + State(state): State, + Json(req): Json, +) -> AppResult> { + let blob = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &req.data) + .map_err(|e| anyhow::anyhow!("invalid base64: {}", e))?; + state.db.friends.insert(auth.fingerprint.as_bytes(), blob)?; + tracing::info!("Saved friend list for {} ({} bytes)", auth.fingerprint, req.data.len()); + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/warzone/crates/warzone-server/src/routes/groups.rs b/warzone/crates/warzone-server/src/routes/groups.rs index f866013..c9768e9 100644 --- a/warzone/crates/warzone-server/src/routes/groups.rs +++ b/warzone/crates/warzone-server/src/routes/groups.rs @@ -281,16 +281,22 @@ async fn get_members( None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), }; - // Resolve aliases for each member + // Resolve aliases and online status for each member let mut members_info: Vec = Vec::new(); + let mut online_count: usize = 0; for fp in &group.members { let alias = state.db.aliases.get(format!("fp:{}", fp).as_bytes()) .ok().flatten() .map(|v| String::from_utf8_lossy(&v).to_string()); + let online = state.is_online(fp).await; + if online { + online_count += 1; + } members_info.push(serde_json::json!({ "fingerprint": fp, "alias": alias, "is_creator": *fp == group.creator, + "online": online, })); } @@ -298,5 +304,6 @@ async fn get_members( "name": group.name, "members": members_info, "count": members_info.len(), + "online_count": online_count, }))) } diff --git a/warzone/crates/warzone-server/src/routes/keys.rs b/warzone/crates/warzone-server/src/routes/keys.rs index 3bf8359..8a778fb 100644 --- a/warzone/crates/warzone-server/src/routes/keys.rs +++ b/warzone/crates/warzone-server/src/routes/keys.rs @@ -46,6 +46,8 @@ struct RegisterRequest { #[serde(default)] device_id: Option, bundle: Vec, + #[serde(default)] + eth_address: Option, } #[derive(Serialize)] @@ -68,6 +70,16 @@ async fn register_keys( let device_key = format!("device:{}:{}", fp, device_id); let _ = state.db.keys.insert(device_key.as_bytes(), req.bundle); + // Store ETH address mapping if provided + if let Some(ref eth) = req.eth_address { + let eth_lower = eth.to_lowercase(); + // eth -> fp + let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes()); + // fp -> eth (reverse lookup) + let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes()); + tracing::info!("ETH address mapped: {} -> {}", eth_lower, fp); + } + tracing::info!("Registered bundle for {} (device: {})", fp, device_id); Json(RegisterResponse { ok: true }) } diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index f81b01b..bfc9161 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,13 +1,16 @@ mod aliases; pub mod auth; +mod bot; mod calls; mod devices; mod federation; +mod friends; mod groups; mod health; mod keys; pub mod messages; mod presence; +mod resolve; mod web; mod ws; mod wzp; @@ -29,7 +32,10 @@ pub fn router() -> Router { .merge(devices::routes()) .merge(presence::routes()) .merge(wzp::routes()) + .merge(friends::routes()) .merge(federation::routes()) + .merge(bot::routes()) + .merge(resolve::routes()) } /// Web UI router (served at root, outside /v1) diff --git a/warzone/crates/warzone-server/src/routes/resolve.rs b/warzone/crates/warzone-server/src/routes/resolve.rs new file mode 100644 index 0000000..9408511 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/resolve.rs @@ -0,0 +1,102 @@ +use axum::{ + extract::{Path, State}, + routing::get, + Json, Router, +}; + +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new().route("/resolve/:address", get(resolve_address)) +} + +/// Resolve an address to a fingerprint. +/// +/// Accepts: ETH address (`0x...`), alias (`@name`), or raw fingerprint. +async fn resolve_address( + State(state): State, + Path(address): Path, +) -> AppResult> { + let addr = address.trim().to_lowercase(); + + // ETH address: 0x... + if addr.starts_with("0x") { + if let Some(fp_bytes) = state.db.eth_addresses.get(addr.as_bytes())? { + let fp = String::from_utf8_lossy(&fp_bytes).to_string(); + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "type": "eth", + }))); + } + // Try federation + if let Some(ref federation) = state.federation { + let url = format!("{}/v1/resolve/{}", federation.config.peer.url, addr); + if let Ok(resp) = federation.client.get(&url).send().await { + if resp.status().is_success() { + if let Ok(data) = resp.json::().await { + if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "type": "eth", + "federated": true, + }))); + } + } + } + } + } + return Ok(Json(serde_json::json!({ "error": "address not found" }))); + } + + // Alias: @name + if addr.starts_with('@') { + let alias = &addr[1..]; + // Try local alias resolution + let alias_key = format!("a:{}", alias); + if let Some(fp_bytes) = state.db.aliases.get(alias_key.as_bytes())? { + let fp = String::from_utf8_lossy(&fp_bytes).to_string(); + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "type": "alias", + }))); + } + // Try federation + if let Some(ref federation) = state.federation { + if let Some(fp) = federation.resolve_remote_alias(alias).await { + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "type": "alias", + "federated": true, + }))); + } + } + return Ok(Json(serde_json::json!({ "error": "alias not found" }))); + } + + // Raw fingerprint: just echo back with optional reverse ETH lookup + let fp = addr + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::(); + if fp.len() == 32 { + let rev_key = format!("rev:{}", fp); + let eth = state + .db + .eth_addresses + .get(rev_key.as_bytes())? + .map(|v| String::from_utf8_lossy(&v).to_string()); + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": fp, + "eth_address": eth, + "type": "fingerprint", + }))); + } + + Ok(Json(serde_json::json!({ "error": "unrecognized address format" }))) +} diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index d2e21ef..9775e9f 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -171,6 +171,9 @@ const WEB_HTML: &str = r##" cursor: pointer; font-size: 14px; min-height: 40px; } #send-btn:hover { background: #c73e54; } + .addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; } + .addr:hover { color: #81d4fa; } + @media (max-width: 500px) { .msg { font-size: 0.8em; } #chat-header input { width: 180px; } @@ -207,6 +210,7 @@ const WEB_HTML: &str = r##"
+ @@ -230,6 +234,7 @@ const $peerInput = document.getElementById('peer-input'); // ── State ── let wasmIdentity = null; // WasmIdentity from WASM let myFingerprint = ''; +let myEthAddress = ''; let mySeedHex = ''; let sessions = {}; // peerFP -> { session: WasmSession, data: base64 } let peerBundles = {}; // peerFP -> bundle bytes @@ -298,6 +303,33 @@ function normFP(fp) { return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); } +function makeAddressClickable(text) { + // Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups) + text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) { + const fp = match.replace(/:/g, ''); + return '' + match + ''; + }); + // Match ETH addresses: 0x followed by 40 hex chars + text = text.replace(/(0x[0-9a-fA-F]{40})/g, function(match) { + return '' + match + ''; + }); + return text; +} + +function handleAddrClick(addr) { + const input = document.getElementById('msg-input'); + if (input && input.value.trim().length > 0) { + navigator.clipboard.writeText(addr).then(() => { + addSys('Copied: ' + addr); + }); + } else { + $peerInput.value = addr; + currentGroup = null; + localStorage.setItem('wz-peer', addr); + addSys('Peer set to ' + addr.slice(0,16) + '...'); + } +} + // ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ── async function initWasm() { @@ -442,6 +474,43 @@ async function sendEncrypted(peerFP, plaintext) { return msgId; } +// URL deep links: /message/@alias, /message/0xABC, /group/#ops +function handleDeepLink() { + const path = window.location.pathname; + if (path.startsWith('/message/')) { + const target = decodeURIComponent(path.slice(9)); + if (target) { + setTimeout(() => { + $peerInput.value = target; + if (target.startsWith('@')) { + fetch(SERVER + '/v1/alias/resolve/' + target.slice(1)).then(r => r.json()).then(data => { + if (!data.error) { + $peerInput.value = data.fingerprint; + currentGroup = null; + localStorage.setItem('wz-peer', data.fingerprint); + addSys('Deep link: peer set to ' + target + ' (' + data.fingerprint.slice(0,16) + '...)'); + } else { + addSys('Deep link: unknown alias ' + target); + } + }); + } else { + currentGroup = null; + localStorage.setItem('wz-peer', target); + addSys('Deep link: peer set to ' + target.slice(0,16) + '...'); + } + }, 500); + } + } else if (path.startsWith('/group/')) { + let group = decodeURIComponent(path.slice(7)); + if (group.startsWith('#')) group = group.slice(1); + if (group) { + setTimeout(() => { + groupSwitch(group); + }, 500); + } + } +} + function connectWebSocket() { const fp = normFP(myFingerprint); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -454,6 +523,7 @@ function connectWebSocket() { ws.onopen = () => { dbg('WebSocket connected'); addSys('Real-time connection established'); + handleDeepLink(); }; ws.onmessage = async (event) => { @@ -669,7 +739,11 @@ function addMsg(from, text, isSelf, messageId) { const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent'; receiptHtml = ' ' + receiptIndicator(status) + ''; } - d.innerHTML = '' + ts() + ' ' + lock + '' + esc(from) + ': ' + esc(text) + receiptHtml; + d.innerHTML = '' + ts() + ' ' + lock + '' + makeAddressClickable(esc(from)) + ': ' + makeAddressClickable(esc(text)) + receiptHtml; + // Attach click handler for .addr spans + d.querySelectorAll('.addr').forEach(el => { + el.addEventListener('click', () => handleAddrClick(el.dataset.addr)); + }); $messages.appendChild(d); $messages.scrollTop = $messages.scrollHeight; // Store reference to the receipt span so we can update it later @@ -724,8 +798,21 @@ async function enterChat() { await registerKey(); addSys('Identity loaded: ' + myFingerprint); addSys('Key registered with server'); + + // Fetch ETH address from server + try { + const resolveResp = await fetch(SERVER + '/v1/resolve/' + normFP(myFingerprint)); + const resolveData = await resolveResp.json(); + if (resolveData.eth_address) { + myEthAddress = resolveData.eth_address; + addSys('ETH: ' + myEthAddress); + document.getElementById('hdr-eth').textContent = myEthAddress.slice(0, 10) + '...'; + document.getElementById('hdr-eth').title = myEthAddress; + } + } catch(e) { dbg('ETH resolve failed:', e); } + addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above'); - addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /file · /info'); + addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info'); const savedPeer = localStorage.getItem('wz-peer'); if (savedPeer) $peerInput.value = savedPeer; @@ -758,6 +845,22 @@ async function groupJoin(name) { addSys('Joined group "' + name + '" (' + data.members + ' members)'); } +async function showGroupMembers(groupName) { + try { + const resp = await fetch(SERVER + '/v1/groups/' + groupName + '/members'); + const data = await resp.json(); + if (data.members && data.members.length > 0) { + const online = data.members.filter(m => m.online).length; + addSys('Members of #' + groupName + ' (' + online + '/' + data.members.length + ' online):'); + for (const m of data.members) { + const status = m.online ? '\u{1F7E2}' : '\u26AA'; + const label = m.alias ? '@' + m.alias : m.fingerprint.slice(0, 16) + '...'; + addSys(' ' + status + ' ' + label + (m.is_creator ? ' *' : '')); + } + } + } catch(e) { dbg('Failed to fetch members:', e); } +} + async function groupSwitch(name) { // Auto-join await groupJoin(name); @@ -767,6 +870,7 @@ async function groupSwitch(name) { currentGroup = name; $peerInput.value = '#' + name; addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')'); + await showGroupMembers(name); } async function groupList() { @@ -838,6 +942,7 @@ async function doSend() { const aliasData = await aliasResp.json(); const aliasStr = aliasData.alias ? ' (@' + aliasData.alias + ')' : ''; addSys('Fingerprint: ' + myFingerprint + aliasStr); + if (myEthAddress) addSys('ETH Address: ' + myEthAddress); return; } if (text === '/clear') { $messages.innerHTML = ''; return; } @@ -871,6 +976,11 @@ async function doSend() { } catch(e) { addSys('Bundle info error: ' + e); } return; } + if (text === '/seed') { + addSys('Your recovery seed (keep secret!):'); + addSys(wasmIdentity.mnemonic()); + return; + } if (text === '/quit') { window.close(); return; } if (text === '/glist') { await groupList(); return; } if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; } @@ -970,6 +1080,32 @@ async function doSend() { } return; } + if (text === '/friend' || text === '/friends') { + try { + const resp = await fetch(SERVER + '/v1/friends', { + headers: { 'Authorization': 'Bearer ' + normFP(myFingerprint) } + }); + const data = await resp.json(); + if (data.data) { + addSys('Friends:'); + addSys('(encrypted friend list stored on server -- use TUI for full friend management)'); + } else { + addSys('No friends yet. Use /friend
to add.'); + } + } catch(e) { addSys('Error: ' + e.message); } + return; + } + if (text.startsWith('/friend ')) { + const addr = text.slice(8).trim(); + if (!addr) { addSys('Usage: /friend
'); return; } + addSys('Friend management requires TUI client (encrypted locally). Use warzone-client for full support.'); + addSys('Hint: /friend in TUI to manage friends with E2E encryption.'); + return; + } + if (text.startsWith('/unfriend ')) { + addSys('Friend management requires TUI client (encrypted locally).'); + return; + } if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; } // Send to group or DM @@ -1021,6 +1157,9 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB document.getElementById('btn-recover').onclick = () => doRecover(); document.getElementById('btn-enter').onclick = () => enterChat(); document.getElementById('send-btn').onclick = () => doSend(); +document.getElementById('hdr-eth').onclick = function() { + if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address')); +}; document.getElementById('file-input').onchange = async function() { if (!this.files.length) return; const file = this.files[0]; diff --git a/warzone/docs/CLIENT.md b/warzone/docs/CLIENT.md index 35d878e..5e501f6 100644 --- a/warzone/docs/CLIENT.md +++ b/warzone/docs/CLIENT.md @@ -1,5 +1,7 @@ # Warzone Client -- Operation Guide +**Version:** 0.0.21 + --- ## 1. Installation @@ -21,313 +23,509 @@ The binary is at `target/release/warzone`. You can copy it anywhere or add cargo install --path crates/warzone-client ``` ---- +### Build the WASM Module (Web Client) -## 2. Quick Start +Requires wasm-pack. ```bash -# 1. Generate a new identity -warzone init - -# 2. Register your key bundle with a server -warzone register -s http://wz.example.com:7700 - -# 3. Send an encrypted message -warzone send a3f8:c912:44be:7d01 "Hello from Warzone" -s http://wz.example.com:7700 - -# 4. Poll for incoming messages -warzone recv -s http://wz.example.com:7700 +cd crates/warzone-wasm +wasm-pack build --target web +# Output in pkg/ — copy to web client directory ``` --- -## 3. CLI Commands +## 2. TUI Architecture -### warzone init +The interactive client is built on **ratatui** (rendering) and **crossterm** +(terminal I/O). The event loop polls at **100 ms** intervals, giving a +responsive feel without busy-waiting. -Generate a new identity (seed, keypair, and pre-keys). +### Module Layout + +The TUI lives in `crates/warzone-client/src/tui/` and is split into seven +modules: + +| Module | Responsibility | +|-----------------|---------------------------------------------------------| +| `types` | Core data structures: `App`, `ChatLine`, `ReceiptStatus`, `PendingFileTransfer`, constants (`MAX_FILE_SIZE`, `CHUNK_SIZE`) | +| `draw` | Rendering: header bar, message list with timestamps and receipt indicators, input box with unread badge, scroll windowing | +| `commands` | All `/`-prefixed command handlers (peer, alias, group, file, history, friends, devices, etc.) and message send logic | +| `input` | Key event dispatch: text editing, cursor movement, scroll, quit | +| `file_transfer` | Chunked file send: reads file, SHA-256 hash, splits into 64 KB encrypted chunks | +| `network` | WebSocket receive loop (with HTTP polling fallback), incoming message decryption, receipt handling, session auto-recovery | +| `mod` | Public entry point `run_tui()`: sets up terminal, spawns network task, runs the 100 ms event loop | + +### Event Loop + +``` +loop { + terminal.draw(app) // ratatui render pass + if event::poll(100ms) { // crossterm poll + handle key event // Enter → send; everything else → input.rs + } + if app.should_quit { break } +} +``` + +Messages arrive asynchronously on a background tokio task (`network::poll_loop`) +and are pushed into a shared `Arc>>`. + +--- + +## 3. CLI Subcommands + +### `warzone init` + +Generate a new identity (seed, keypair, pre-keys). ```bash $ warzone init -Identity generated! +Set passphrase (empty for no encryption): **** +Confirm passphrase: **** -Fingerprint: b7d1:e845:0022:9f3a +Your identity: + Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 + Mnemonic: abandon ability able about above absent absorb abstract ... -Recovery mnemonic (WRITE THIS DOWN): - - 1. abandon 2. ability 3. able 4. about - 5. above 6. absent 7. absorb 8. abstract - 9. absurd 10. abuse 11. access 12. accident -13. account 14. accuse 15. achieve 16. acid -17. acoustic 18. acquire 19. across 20. act -21. action 22. actor 23. actress 24. actual - -Seed saved to ~/.warzone/identity.seed -Generated 1 signed pre-key + 10 one-time pre-keys - -To register with a server, run: - warzone send -s http://server:7700 - -Or register your key bundle manually: - (bundle auto-registered on first send) +SAVE YOUR MNEMONIC — it is the ONLY way to recover your identity. ``` **What happens:** + 1. Generates 32 random bytes (seed) from `OsRng`. 2. Derives Ed25519 signing key and X25519 encryption key from the seed. 3. Converts seed to a 24-word BIP39 mnemonic and displays it. -4. Saves the raw seed to `~/.warzone/identity.seed` (mode 0600 on Unix). +4. Prompts for a passphrase. Encrypts the seed with Argon2id + ChaCha20-Poly1305 + and saves to `~/.warzone/identity.seed` (mode 0600 on Unix). An empty + passphrase stores the seed in plaintext. 5. Generates 1 signed pre-key (id=1) and 10 one-time pre-keys (ids 0-9). 6. Stores pre-key secrets in the local sled database at `~/.warzone/db/`. 7. Saves the public pre-key bundle to `~/.warzone/bundle.bin`. --- -### warzone recover \ +### `warzone recover ` -Recover an identity from a BIP39 mnemonic. +Recover an identity from a 24-word BIP39 mnemonic. ```bash $ warzone recover abandon ability able about above absent absorb abstract \ absurd abuse access accident account accuse achieve acid \ acoustic acquire across act action actor actress actual -Identity recovered! -Fingerprint: b7d1:e845:0022:9f3a -Seed saved to ~/.warzone/identity.seed +Set passphrase (empty for no encryption): **** +Confirm passphrase: **** +Identity recovered. Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 ``` -**Note:** recovery restores the seed and keypair but does NOT restore -pre-keys or sessions. You will need to run `warzone init`-style pre-key -generation separately or your contacts will need to re-establish sessions. +Recovery restores the seed and keypair. Pre-keys and sessions are NOT restored; +contacts will need to re-establish sessions. --- -### warzone info +### `warzone info` Display your fingerprint and public keys. ```bash $ warzone info -Fingerprint: b7d1:e845:0022:9f3a -Signing key: 3a7c... (64 hex chars) -Encryption key: 9d2f... (64 hex chars) +Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 +Signing key: 3a7b... (64 hex chars) +Encryption key: 9f2c... (64 hex chars) ``` Requires a saved identity (`~/.warzone/identity.seed`). --- -### warzone register +### `warzone tui` / `warzone chat [peer]` -Register your pre-key bundle with a server. +Launch the interactive TUI client. ```bash -$ warzone register -s http://wz.example.com:7700 -Bundle registered with http://wz.example.com:7700 +$ warzone chat --server http://wz.example.com:7700 +$ warzone chat a3f8:c912:44be:7d01:... --server http://wz.example.com:7700 +$ warzone chat @alice --server http://wz.example.com:7700 ``` +An optional `peer` argument (fingerprint or `@alias`) pre-sets the active +DM target. + **Flags:** -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--server` | `-s` | `http://localhost:7700` | Server URL | - -This uploads `~/.warzone/bundle.bin` to the server. Registration is also -performed automatically on the first `send`. +| Flag | Short | Default | Description | +|------------|-------|-----------------------|--------------| +| `--server` | `-s` | `http://localhost:7700` | Server URL | --- -### warzone send +### `warzone send ` -Send an encrypted message to a recipient. +Send an encrypted message. Recipient can be a fingerprint or `@alias`. ```bash -$ warzone send a3f8:c912:44be:7d01 "Hello, are you safe?" -s http://wz.example.com:7700 -No existing session. Fetching key bundle for a3f8:c912:44be:7d01... -Bundle registered with http://wz.example.com:7700 -Message sent to a3f8:c912:44be:7d01 +$ warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://wz.example.com:7700 +$ warzone send @alice "Hello!" --server http://wz.example.com:7700 ``` -**Arguments:** - -| Argument | Description | -|----------|-------------| -| `recipient` | Recipient fingerprint (e.g. `a3f8:c912:44be:7d01`) | -| `message` | Message text (quote if it contains spaces) | - -**Flags:** - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--server` | `-s` | `http://localhost:7700` | Server URL | - **Behavior:** -1. Auto-registers your bundle with the server (if not already done). + +1. Auto-registers your bundle with the server if needed. 2. Checks for an existing Double Ratchet session with the recipient. -3. If no session exists: - - Fetches recipient's pre-key bundle from the server. - - Verifies the signed pre-key signature. - - Performs X3DH key exchange. - - Initializes the Double Ratchet as Alice (initiator). - - Sends a `WireMessage::KeyExchange` containing the X3DH parameters - and the first encrypted message. -4. If a session exists: - - Encrypts using the existing ratchet. - - Sends a `WireMessage::Message`. +3. If no session: fetches the recipient's pre-key bundle, verifies the signed + pre-key signature, performs X3DH, initializes the ratchet as Alice, and + sends a `WireMessage::KeyExchange` containing the X3DH parameters and the + first encrypted message. +4. If a session exists: encrypts with the existing ratchet and sends a + `WireMessage::Message`. 5. Updates the local session state. --- -### warzone recv +### `warzone recv` Poll for and decrypt incoming messages. ```bash -$ warzone recv -s http://wz.example.com:7700 -Polling for messages as b7d1:e845:0022:9f3a... -Received 2 message(s): - - [new session] a3f8:c912:44be:7d01: Hello, are you safe? - a3f8:c912:44be:7d01: I'm sending supplies tomorrow. +$ warzone recv --server http://wz.example.com:7700 ``` -**Flags:** - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--server` | `-s` | `http://localhost:7700` | Server URL | - -**Behavior:** -1. Polls `/v1/messages/poll/{our_fingerprint}`. -2. For each message: - - Deserializes the `WireMessage` from bincode. - - **KeyExchange:** loads signed pre-key secret and (if applicable) - one-time pre-key secret from local storage, performs X3DH respond, - initializes ratchet as Bob, decrypts the message, and saves the session. - - **Message:** loads existing session, decrypts with the ratchet, saves - updated session state. -3. Prints decrypted messages to stdout. - -**Note:** messages are currently NOT acknowledged after polling. They will -be returned again on the next poll. Acknowledgment is TODO. +Fetches messages from `/v1/messages/poll/{fingerprint}`, deserializes each +`WireMessage`, performs X3DH respond or ratchet decrypt as appropriate, and +prints plaintext to stdout. --- -### warzone chat +### `warzone backup [output]` -Launch the interactive TUI. +Export an encrypted backup of local data (sessions, pre-keys). ```bash -$ warzone chat -s http://wz.example.com:7700 -TODO: launch TUI connected to http://wz.example.com:7700 +$ warzone backup my-backup.wzb +Backup saved to my-backup.wzb (4096 bytes encrypted) ``` -**Status:** not yet implemented. The TUI will use `ratatui` and `crossterm` -(dependencies are already in `Cargo.toml`). Planned for Phase 2. +The backup is encrypted with `HKDF(seed, info="warzone-history")` + +ChaCha20-Poly1305. + +**Backup file format:** + +``` +WZH1 (4 bytes) + nonce (12) + ciphertext + +Plaintext: JSON { + "version": 1, + "sessions": { "": "base64_bincode", ... }, + "pre_keys": { "spk:1": "base64_bytes", "otpk:1": "base64_bytes", ... } +} +``` --- -## 4. Identity Management +### `warzone restore ` -### Storage Layout - -``` -~/.warzone/ - identity.seed # 32-byte raw seed (plaintext -- encryption is TODO) - bundle.bin # bincode-serialized PreKeyBundle (public data) - db/ # sled database directory - sessions/ # Double Ratchet state per peer - pre_keys/ # signed and one-time pre-key secrets -``` - -### File Permissions - -On Unix, `identity.seed` is created with mode `0600` (owner read/write only). -The sled database directory inherits default permissions. - -### Seed Security - -**Current state:** the seed is stored as **plaintext** 32 bytes. This is a -known Phase 1 limitation. - -**Planned (Phase 2):** encrypt the seed at rest using: -- Passphrase input at startup -- Argon2id key derivation from passphrase -- ChaCha20-Poly1305 encryption of the seed bytes - -### Mnemonic Backup - -The 24-word BIP39 mnemonic shown during `init` is the ONLY way to recover -your identity if you lose `~/.warzone/`. Write it down on paper and store it -securely. - -The mnemonic is displayed once at generation time and can be recovered from -the seed using the protocol library, but the CLI does not currently expose a -"show mnemonic" command. - -### Recovery +Restore from an encrypted backup. Requires the same seed (passphrase prompt). ```bash -warzone recover word1 word2 word3 ... word24 +$ warzone restore my-backup.wzb +Restored 12 entries from my-backup.wzb ``` -This recreates `~/.warzone/identity.seed` with the same seed. The same -fingerprint and keypairs are derived deterministically. However: - -- Pre-keys are NOT regenerated. Run `warzone init` on a fresh directory to - generate new pre-keys (this will also generate a new seed, so you would need - to coordinate). -- Sessions are NOT recovered. All contacts will need to establish new sessions. - -**TODO:** a `recover` flow that also regenerates pre-keys without creating a -new seed. +Merges data without overwriting existing entries. --- -## 5. Web Client +## 4. TUI Features -The web client is served by the server at `/`. Open it in a browser: +### Message Timestamps -``` -http://localhost:7700/ -``` +Every message is rendered with a `[HH:MM]` prefix in dark gray, derived from +`chrono::Local::now()` at receive/send time. -### Features +### Message Scrolling -- **Generate New Identity:** creates a random 32-byte seed in the browser. -- **Recover from Mnemonic:** paste a hex-encoded seed (not BIP39 words; - hex encoding is used as a placeholder). -- **Chat interface:** dark-themed monospace UI with message display. -- **Commands:** - - `/help` -- show available commands - - `/info` -- show your fingerprint - - `/seed` -- display your seed (hex-encoded) +The message area supports scrolling with a "pinned to bottom" model: + +- `scroll_offset = 0` means the newest messages are visible. +- Scrolling up increases the offset; scrolling down decreases it. +- The visible window is computed as `items[total - offset - height .. total - offset]`. + +### Connection Status Indicator + +The header bar displays a colored dot after the server URL: + +- Green dot: WebSocket connection active. +- Red dot: disconnected (HTTP polling fallback or reconnecting). + +### Unread Badge + +When `scroll_offset > 0`, the input box title changes from `" message "` to +`" [N new] "` showing how many messages are below the current scroll position. +This makes it obvious that new content has arrived while reading history. + +### Terminal Bell + +A terminal bell (`\x07`) is emitted on every incoming DM (both `KeyExchange` +and `Message` wire types). This triggers a system notification in most terminal +emulators. + +### Receipt Indicators + +Sent messages display delivery status after the message text: + +| Indicator | Meaning | +|-----------------|----------------------------------------| +| Single tick | Sent (no confirmation yet) | +| Double tick | Delivered (decrypted by recipient) | +| Double tick blue| Read (viewed by recipient) | + +### Session Auto-Recovery + +When decryption fails on an incoming message, the TUI automatically: + +1. Deletes the corrupted session from the local database. +2. Displays a system message: `[session reset] Decryption failed for . Session cleared -- next message will re-establish.` + +The next incoming `KeyExchange` from that peer will create a fresh session +without manual intervention. + +--- + +## 5. Full Command Reference + +All commands start with `/` and are entered in the TUI input box. + +### Peer and Navigation + +| Command | Short | Description | +|------------------------|---------|----------------------------------------------| +| `/peer ` | `/p` | Set the active DM peer (fingerprint or @alias) | +| `/dm` | | Switch to DM mode (clear group context) | +| `/reply` | `/r` | Switch to the last person who DM'd you | +| `/info` | | Display your fingerprint | +| `/eth` | | Display your Ethereum address (derived from seed) | +| `/seed` | | Display your 24-word recovery mnemonic | +| `/quit` | `/q` | Exit the TUI | +| `/help` | `/?` | Show the built-in help text | + +### Alias Management + +| Command | Description | +|-----------------------|--------------------------------------------| +| `/alias ` | Register an alias for your fingerprint. Returns a recovery key -- save it. | +| `/unalias` | Remove your alias from the server | +| `/aliases` | List all registered aliases on the server | + +Alias rules: 1-32 alphanumeric characters (plus `_` and `-`), case-insensitive, +normalized to lowercase. TTL is 365 days of inactivity with a 30-day grace +period before reclamation. + +### Contacts and History + +| Command | Short | Description | +|------------------------|---------|------------------------------------------| +| `/contacts` | `/c` | List all contacts with message counts | +| `/history [peer]` | `/h` | Show message history (last 50 messages). Uses current peer if set. | + +### Group Commands + +| Command | Description | +|-------------------------|------------------------------------------| +| `/g ` | Switch to group (auto-join if needed) | +| `/gcreate ` | Create a new group (you become creator) | +| `/gjoin ` | Join an existing group | +| `/gleave` | Leave the current group | +| `/gkick ` | Kick a member (creator only) | +| `/gmembers` | List members of the current group | +| `/glist` | List all groups on the server | + +Group messages use Sender Keys for O(1) encryption per message. Each member +generates a `SenderKey` distributed via 1:1 encrypted channels. Keys rotate on +member join/leave. + +### File Transfer + +| Command | Description | +|-------------------|----------------------------------------------| +| `/file ` | Send a file to the current peer or group | + +Constraints: + +- Maximum file size: 10 MB +- Chunk size: 64 KB +- Files are sent as `FileHeader` + encrypted `FileChunk` wire messages +- SHA-256 verification on receipt +- Received files are saved to `~/.warzone/downloads/` + +### Device Management + +| Command | Description | +|-----------------------|------------------------------------------| +| `/devices` | List your active device sessions | +| `/kick ` | Kick a specific device session | + +--- + +## 6. Keyboard Shortcuts + +### Text Editing + +| Key | Action | +|------------------|---------------------------------| +| Left / Right | Move cursor one character | +| Home / Ctrl+A | Move to beginning of line | +| End / Ctrl+E | Move to end of line | +| Backspace | Delete character before cursor | +| Delete | Delete character at cursor | +| Ctrl+U | Clear entire input line | +| Ctrl+K | Kill from cursor to end of line | +| Ctrl+W | Delete word before cursor | +| Alt+Backspace | Delete word before cursor | +| Alt+Left | Jump one word left | +| Alt+Right | Jump one word right | + +### Scrolling + +| Key | Action | +|------------------|------------------------------------------| +| PageUp | Scroll up 10 messages | +| PageDown | Scroll down 10 messages | +| Up | Scroll up 1 message (when input is empty)| +| Down | Scroll down 1 message (when input is empty)| +| End | Snap to bottom (when input is empty) | +| Ctrl+End | Snap to bottom (always) | + +### Quit + +| Key | Action | +|------------------|---------| +| Ctrl+C | Quit | +| Esc | Quit | + +--- + +## 7. Friend List + +The friend list is an E2E encrypted contact list stored on the server as an +opaque blob. The server never sees the plaintext. + +### Encryption + +- Key derivation: `HKDF(seed, info="warzone-friends")` produces a 32-byte key. +- Encryption: ChaCha20-Poly1305 with AAD `"warzone-friends-aad"`. +- Plaintext format: JSON-serialized `FriendList` containing address, alias, + and `added_at` timestamp per friend. + +### Commands + +| Command | Description | +|------------------------|------------------------------------------------| +| `/friend` | List all friends with online/offline presence | +| `/friend
` | Add a friend (fingerprint or ETH address) | +| `/unfriend
` | Remove a friend | + +When listing friends, the TUI queries the server's presence endpoint for each +friend to show real-time online/offline status. ### How It Works -1. Seed is generated with `crypto.getRandomValues(32)`. -2. ECDH P-256 keypair is derived (not X25519 -- Web Crypto limitation). -3. Fingerprint is `SHA-256(ECDH_public_key)[0..16]` formatted as 4 hex - groups. -4. Seed is saved in `localStorage` under key `wz-seed`. -5. On page load, the client tries to auto-load a saved seed. -6. Public key is registered with the server via `POST /v1/keys/register`. -7. Messages are polled every 5 seconds from `/v1/messages/poll/{fingerprint}`. +1. On `/friend
`: the client fetches the current encrypted blob from + the server, decrypts it, adds the entry, re-encrypts, and uploads. +2. On `/unfriend
`: same fetch-decrypt-modify-encrypt-upload cycle. +3. On `/friend` (no argument): fetches and decrypts the blob, then checks + `/v1/presence/` for each friend. -### Limitations - -- **No cross-client compatibility:** the web client uses P-256 while the CLI - uses X25519/Ed25519. Messages between the two cannot be decrypted. This - will be resolved in Phase 2 (WASM port of the protocol library). -- **No Double Ratchet:** message decryption is not implemented in JS. - Received messages display as `[encrypted message]`. -- **No BIP39:** seed is shown as hex bytes, not mnemonic words. -- **Unencrypted seed storage:** `localStorage` is accessible to any JS on - the same origin. +The server stores the blob at `POST /v1/friends` and returns it at +`GET /v1/friends`. It has no knowledge of the contents. --- -## 6. Session Management +## 8. Local Storage + +### Directory Layout + +``` +~/.warzone/ + identity.seed # Encrypted seed (Argon2id + ChaCha20-Poly1305) + bundle.bin # bincode-serialized PreKeyBundle (public data) + db/ # sled database directory + sessions/ # Double Ratchet state per peer (keyed by hex fingerprint) + pre_keys/ # Signed and one-time pre-key secrets + contacts/ # Contact metadata and message counts + history/ # Message history per peer + sender_keys/ # Sender Key state for group encryption + downloads/ # Received files from /file transfers +``` + +### Seed Encryption + +The seed file uses a fixed format: + +``` +WZS1 (4 bytes magic) + salt (16) + nonce (12) + ciphertext (48) + +Encryption: Argon2id(passphrase, salt) -> 32-byte key + ChaCha20-Poly1305(key, nonce, seed) -> ciphertext +``` + +An empty passphrase at `init` time stores the seed in plaintext (for testing +only). The seed file is created with mode `0600` (owner read/write) on Unix. + +### Mnemonic Backup + +The 24-word BIP39 mnemonic shown during `init` is the only way to recover +your identity if you lose `~/.warzone/`. Write it down on paper. You can also +view it later with `/seed` in the TUI. + +--- + +## 9. Web Client + +The web client is served by the server at `/` and uses a **WASM bridge** +(`warzone-wasm`) that exposes the exact same cryptographic primitives as the +CLI: X25519, ChaCha20-Poly1305, X3DH, Double Ratchet. + +### Features + +- **Same crypto as TUI:** the WASM module wraps `warzone-protocol` directly, + so web-to-CLI interoperability is fully supported. +- **URL deep links:** paths like `/message/@alias`, `/message/0xABC`, and + `/group/#ops` auto-navigate to the corresponding conversation. +- **Clickable addresses:** fingerprints and aliases in the chat are rendered + as interactive links. +- **Service worker cache:** all shell assets (`/`, WASM JS, WASM binary, + manifest, icon) are cached by a versioned service worker (`wz-v2`). The + cache name is bumped on updates to force refresh. +- **PWA support:** includes a manifest and install prompt (`/install` command). +- **BIP39 mnemonic:** seed is displayed as 24 words via the WASM bridge + (not hex). + +### Web-Only Commands + +| Command | Description | +|-------------------|----------------------------------------------------| +| `/selftest` | Run WASM crypto self-test (X3DH + ratchet cycle) | +| `/bundleinfo` | Debug: show bundle details (keys, sizes) | +| `/debug` | Toggle debug mode (verbose output) | +| `/reset` | Clear identity and all local data | +| `/install` | Show PWA installation instructions | +| `/sessions` | List active ratchet sessions | +| `/admin-unalias` | Admin: remove any alias (requires admin password) | + +### Web Client Storage + +Data is stored in `localStorage`: + +| Key | Value | Purpose | +|----------------------|--------------------------------|----------------------------| +| `wz_seed` | hex seed (64 chars) | Identity seed | +| `wz_spk_secret` | hex SPK secret (64 chars) | Signed pre-key secret | +| `wz_session:` | base64 ratchet state | Per-peer session | +| `wz_contacts` | JSON contact list | Contact metadata | + +--- + +## 10. Session Management ### How Sessions Work @@ -336,172 +534,63 @@ by their fingerprint. 1. **First message to a peer:** X3DH key exchange establishes a shared secret. The ratchet is initialized. The session is saved in `~/.warzone/db/` - under the `sessions` tree, keyed by the peer's fingerprint (hex-encoded). + under the `sessions` tree, keyed by the peer's hex fingerprint. 2. **Subsequent messages:** the ratchet state is loaded, used to encrypt or decrypt, then saved back. -3. **Bidirectional:** both parties maintain the same session. When Bob - receives Alice's KeyExchange, he initializes his side of the ratchet. From - then on, both use `WireMessage::Message`. +3. **Bidirectional:** when Bob receives Alice's `KeyExchange`, he initializes + his side. From then on, both use `WireMessage::Message`. -### Session Storage +### Session Auto-Recovery -Sessions are serialized with `bincode` and stored in the `sessions` sled -tree. The key is the peer's 32-character hex fingerprint. +On decrypt failure, the TUI deletes the corrupted session and displays a +warning. The next incoming `KeyExchange` from that peer re-establishes the +session automatically. No manual intervention required. -### Session Reset +### Multi-Device -There is currently no command to reset a session. If a session becomes -corrupted or out of sync: - -1. Delete the local database: `rm -rf ~/.warzone/db/` -2. Re-run `warzone init` to generate new pre-keys. -3. Re-register with the server. -4. Your contact must also reset their session with you. - -**TODO (Phase 2):** a `warzone reset-session ` command. +The server stores per-device bundles (`device::`). Multiple +WebSocket connections per fingerprint are supported -- all connected devices +receive messages. Ratchet sessions are per-device and not synchronized; use +`warzone backup` / `warzone restore` to transfer session state. --- -## 7. Pre-Key Management - -### What Are Pre-Keys - -Pre-keys enable asynchronous session establishment. When Alice wants to -message Bob for the first time: - -1. Alice fetches Bob's **pre-key bundle** from the server. -2. The bundle contains Bob's public identity key, a signed pre-key, and - optionally a one-time pre-key. -3. Alice uses these to perform X3DH without Bob being online. - -### Pre-Key Types - -| Type | Quantity | Lifetime | Purpose | -|------|----------|----------|---------| -| Signed pre-key | 1 (id=1) | Long-term (no rotation yet) | Medium-term DH key, signed by identity | -| One-time pre-keys | 10 (ids 0-9) | Single use | Consumed during X3DH, then deleted | - -### When to Replenish - -One-time pre-keys are consumed when someone initiates a session with you. -After all 10 are used, X3DH falls back to using only the signed pre-key -(DH4 is skipped), which provides slightly weaker security properties. - -**Current state:** there is no automatic replenishment. You must manually -re-initialize if you expect many incoming new sessions. - -**TODO (Phase 2):** the server will notify the client when one-time pre-key -supply is low, and the client will upload fresh ones automatically. - ---- - -## 8. Security Model - -### What Is Encrypted - -- **Message body:** encrypted with ChaCha20-Poly1305 using per-message keys - from the Double Ratchet. Even the server cannot read it. - -### What Is NOT Encrypted - -- **Sender fingerprint:** visible to the server and anyone intercepting - traffic. -- **Recipient fingerprint:** visible to the server (needed for routing). -- **Message size:** visible to the server. -- **Timing:** when messages are sent and received. -- **IP addresses:** visible to the server and network observers. -- **Seed on disk:** stored as plaintext (encryption TODO). - -### Threat Model - -| Threat | Protected? | Notes | -|--------|-----------|-------| -| Server reads messages | Yes | E2E encryption; server sees only ciphertext | -| Network eavesdropper reads messages | Yes | E2E encryption | -| Server impersonates a user | Partially | Pre-key signatures prevent forgery of signed pre-keys, but the server could substitute a fake bundle (no key transparency yet) | -| Compromised past session key | Yes | Forward secrecy via chain ratchet; break-in recovery via DH ratchet | -| Stolen device (seed file) | No | Seed is plaintext on disk (encryption TODO) | -| Metadata analysis (who talks to whom) | No | Fingerprints visible to server | -| Active MITM on first contact | Partially | TOFU model; no out-of-band verification mechanism in the client yet | -| One-time pre-keys exhausted | Graceful degradation | X3DH works without OT pre-keys but with reduced replay protection | - -### Trust Model - -**Trust on first use (TOFU):** the first time you message someone, you trust -that the server returns their genuine pre-key bundle. There is no -verification step yet. - -**Planned (Phase 3):** DNS-based key transparency where users publish -self-signed public keys in DNS TXT records, allowing cross-verification -independent of the server. - ---- - -## 9. Troubleshooting +## 11. Troubleshooting ### "No identity found. Run `warzone init` first." -You haven't generated an identity, or `~/.warzone/identity.seed` is missing. - -```bash -warzone init -``` +`~/.warzone/identity.seed` is missing. Run `warzone init`. ### "No bundle found. Run `warzone init` first." -The pre-key bundle file `~/.warzone/bundle.bin` is missing. This happens if -you ran `recover` without a full `init`. - -Re-run `warzone init` (this will generate a NEW identity). To keep your -recovered identity, you would need to manually regenerate pre-keys (not yet -supported as a standalone command). +`~/.warzone/bundle.bin` is missing. This happens if you ran `recover` without +regenerating pre-keys. Re-run `warzone init` (generates a new identity). ### "failed to fetch recipient's bundle. Are they registered?" -The recipient has not registered their pre-key bundle with the server, or -you are using the wrong server URL, or the fingerprint is incorrect. - -- Verify the fingerprint (ask the recipient for theirs via `warzone info`). -- Verify the server URL. -- Ask the recipient to run `warzone register -s `. +The recipient has not registered with the server, or the fingerprint / alias +is wrong, or the server URL is incorrect. Verify with `warzone info` and +`warzone register`. ### "X3DH respond failed" / "missing signed pre-key" -Your signed pre-key secret is missing from the local database. This can -happen if: -- The database was deleted or corrupted. -- You recovered an identity but did not regenerate pre-keys. +Signed pre-key secret missing from local database. Database may have been +deleted or corrupted. Re-initialize with `warzone init`. -Fix: re-initialize with `warzone init` (generates a new identity) or restore -from backup. +### "[session reset] Decryption failed" -### "decrypt failed" / "no session" - -- **"no session"**: you received a `WireMessage::Message` from someone you - have no session with. This means you missed their initial `KeyExchange` - message, or your session database was lost. Ask them to re-send their first - message. -- **"decrypt failed"**: the ratchet state is out of sync. This can happen if - one side's state was lost or if messages were duplicated. Reset the session - on both sides. - -### Messages Keep Reappearing on recv - -Messages are not auto-acknowledged after polling. This is a known Phase 1 -limitation. The same messages will be returned on every `recv` call. - -**Workaround:** none currently. Acknowledgment will be added in Phase 2. +The TUI auto-recovery has cleared the corrupted session. Ask the other party +to send a new message -- a fresh `KeyExchange` will re-establish the session. ### Corrupted Database -If `~/.warzone/db/` is corrupted: - ```bash +# Back up your seed first +cp ~/.warzone/identity.seed ~/identity.seed.bak rm -rf ~/.warzone/db/ warzone init # regenerate pre-keys (NOTE: generates a new identity) +# To keep your old identity, recover from mnemonic after: +warzone recover <24 words> ``` - -To keep your existing identity, manually copy `identity.seed` before -deleting, then use `warzone recover` after re-init. diff --git a/warzone/docs/LLM_HELP.md b/warzone/docs/LLM_HELP.md new file mode 100644 index 0000000..0bcd2f8 --- /dev/null +++ b/warzone/docs/LLM_HELP.md @@ -0,0 +1,159 @@ +# featherChat Help Reference + +featherChat (codename: warzone) = E2E encrypted messenger. TUI client, web client (WASM), federated servers. Crypto: X3DH key exchange + Double Ratchet. Identity = Ed25519 keypair from 24-word seed. + +## Commands + +cmd | action | example +--- | --- | --- +/help, /? | show help | /help +/info | show your fp | /info +/eth | show ETH addr | /eth +/seed | show 24-word recovery mnemonic | /seed +/peer , /p | set DM peer | /peer abc123 or /peer @alice +/reply, /r | reply to last DM sender | /r +/dm | switch to DM mode (clear peer) | /dm +/contacts, /c | list contacts + msg counts | /c +/history, /h [fp] | show conversation history (50 msgs) | /h abc123 +/alias | register alias for yourself | /alias alice +/aliases | list all registered aliases | /aliases +/unalias | remove your alias | /unalias +/friend | list friends + online status | /friend +/friend | add friend | /friend @bob +/unfriend | remove friend | /unfriend @bob +/devices | list active device sessions | /devices +/kick | kick a device session | /kick dev_abc +/g | switch to group (auto-join) | /g ops +/gcreate | create group | /gcreate ops +/gjoin | join group | /gjoin ops +/glist | list all groups | /glist +/gleave | leave current group | /gleave +/gkick | kick member (creator only) | /gkick abc123 +/gmembers | list group members + status | /gmembers +/file | send file (max 10MB, 64KB chunks) | /file ./doc.pdf +/quit, /q | exit | /q + +Navigation: PageUp/PageDown scroll msgs, Up/Down scroll by 1 (empty input), Ctrl+C or Esc quit. + +## Addressing + +Format | Example | Notes +--- | --- | --- +Fingerprint | abc123def456... | hex string, derived from Ed25519 pubkey +ETH address | 0x742d35Cc... | derived from same seed, checksum format +@alias | @alice | 1-32 alphanum chars, globally unique, 365d TTL + +All 3 formats work in /peer. Aliases resolve to fp via server. One alias per user. Register with /alias, recover with recovery key. + +## Quick Start + +1. `warzone init` -- generates seed, saves identity.seed, prints 24-word mnemonic. WRITE IT DOWN. +2. `warzone register --server https://srv.example.com` -- uploads prekey bundle to srv +3. `warzone tui --server https://srv.example.com` -- opens TUI, connects WebSocket +4. `/peer @alice` or `/peer ` -- set recipient +5. Type msg, press Enter -- encrypted + sent + +Recovery: `warzone recover` -- enter 24 words to restore identity on new device. + +## Groups + +- /gcreate -- create, you become creator + first member +- /gjoin -- join existing (or auto-join via /g ) +- type msg in group mode -- fan-out encrypted per-member (sender keys) +- /gleave -- leave current group +- /gmembers -- shows fp, alias, online status, creator flag +- /gkick -- creator only, removes member + +Groups auto-create on join if they don't exist. Server fans out per-member encrypted msgs. + +## Files + +/file -- sends to current peer/group. Max 10MB. Auto-chunked at 64KB. Includes filename, size, SHA-256 hash. Receiver auto-reassembles. + +## Friends + +- /friend -- list all friends with online/offline status +- /friend -- add (fp, ETH, or @alias) +- /unfriend -- remove +- Friend list stored encrypted on srv (only you can decrypt with your seed) +- Shows alias resolution + presence status + +## Devices + +- /devices -- list active WS connections (device_id, connected_at) +- /kick -- revoke specific device +- Max 5 concurrent device sessions +- /devices/revoke-all API endpoint = panic button (kills all except current) + +## Security + +- Seed = 24-word BIP39 mnemonic = master key. Derives Ed25519 identity + ETH wallet. +- NEVER share seed. Only way to recover account. +- X3DH key exchange establishes sessions. Double Ratchet provides forward secrecy. +- All DMs E2E encrypted. Group msgs encrypted per-member. +- Server sees: metadata (who talks to whom, timestamps), encrypted blobs, presence. +- Server cannot read msg content. +- Pre-keys: signed pre-key + 10 one-time pre-keys uploaded on register. +- Bot API msgs are NOT E2E encrypted (plaintext JSON envelopes). + +## Federation + +- 2 servers connected via persistent WebSocket +- Config: JSON file with server_id, shared_secret, peer URL +- Messages auto-route across servers (srv checks remote presence) +- Aliases globally unique across federation +- @alias resolution checks local first, then federated peer +- Same client commands work regardless of which srv peer is on +- Auto-reconnects on connection failure + +## Web Client + +- Browser access at server root URL (/) +- WASM-compiled client, same crypto as TUI +- PWA: installable, offline-capable (service worker caches shell) +- Same E2E encryption as native client +- Deep links: navigate to specific peers/groups via URL + +## Troubleshooting + +Problem | Cause | Fix +--- | --- | --- +"peer not registered" | recipient hasn't run register yet | they need to `warzone register` +"session reset" | crypto session re-established | normal after key rotation or recovery, msgs continue +"connection lost" | WS disconnected | auto-reconnects, no action needed +"alias already taken" | someone else has it | pick different name or wait for 365d expiry + 30d grace +"not a member" | sending to group you left | /gjoin first +"invalid token" | bot token expired or wrong | re-register bot +"file too large" | over 10MB | split file manually +no prekeys available | recipient's one-time prekeys exhausted | they need to re-register or come online + +## Bot API + +Telegram-compatible REST API. Base: /v1/ + +Endpoint | Method | Body | Returns +--- | --- | --- | --- +/bot/register | POST | {"name":"mybot","fingerprint":"abc..."} | {"token":"...","name":"..."} +/bot/:token/getMe | GET | -- | bot info +/bot/:token/getUpdates | POST | {"timeout":5} | array of Update objects +/bot/:token/sendMessage | POST | {"chat_id":"","text":"hello"} | msg confirmation + +- Token format: fp_prefix:random_hex +- getUpdates: long-poll (max 5s), returns then deletes queued msgs +- sendMessage: plaintext JSON, NOT E2E encrypted +- Updates include: messages, key exchanges, call signals, file headers +- Bot msgs delivered via same routing (WS push or DB queue) + +## Server API (other endpoints) + +- POST /v1/register -- upload prekey bundle +- GET /v1/keys/:fp -- fetch prekeys for peer +- POST /v1/send -- send encrypted msg +- GET /v1/receive/:fp -- poll msgs (WS preferred) +- WS /v1/ws?fp=&token= -- real-time connection +- GET /v1/presence/:fp -- check online status +- GET/POST /v1/friends -- encrypted friend list +- GET /v1/devices -- list sessions +- POST /v1/devices/:id/kick -- kick device +- Alias routes under /v1/alias/* +- Group routes under /v1/groups/* diff --git a/warzone/docs/SERVER.md b/warzone/docs/SERVER.md index 36a6077..6d0d712 100644 --- a/warzone/docs/SERVER.md +++ b/warzone/docs/SERVER.md @@ -1,37 +1,63 @@ -# Warzone Server -- Operation & Administration +# Warzone Server -- Administration Guide + +**Version 0.0.21** --- ## 1. Building -The server is part of the Cargo workspace. From the workspace root: +### Local Build + +From the workspace root: ```bash -# Debug build +# Debug cargo build -p warzone-server -# Release build (recommended for deployment) +# Release (recommended for deployment) cargo build -p warzone-server --release ``` -The resulting binary is at `target/release/warzone-server` (or -`target/debug/warzone-server`). It is a single statically-linked binary with -no runtime dependencies beyond libc. +Binary output: `target/release/warzone-server`. + +### Cross-Compile for Linux (x86_64) + +The `scripts/build-linux.sh` script spins up a Hetzner Cloud VPS, builds +Linux release binaries, and pulls them back to `target/linux-x86_64/`. + +```bash +# Full pipeline: build + deploy to all production servers + destroy VM +./scripts/build-linux.sh --ship + +# Step-by-step: +./scripts/build-linux.sh --prepare # create VM, install deps, upload source +./scripts/build-linux.sh --build # compile release binaries on the VM +./scripts/build-linux.sh --transfer # download binaries to target/linux-x86_64/ +./scripts/build-linux.sh --destroy # delete the VM + +# Or all three build steps at once (VM persists): +./scripts/build-linux.sh --all +``` ### Minimum Rust Version -Rust 1.75 or later (set via `rust-version = "1.75"` in `Cargo.toml`). +Rust 1.75 or later (`rust-version = "1.75"` in `Cargo.toml`). --- ## 2. Running +### Basic + ```bash -# Default: bind 0.0.0.0:7700, data in ./warzone-data +# Defaults: bind 0.0.0.0:7700, data in ./warzone-data ./warzone-server # Custom bind address and data directory -./warzone-server --bind 127.0.0.1:8080 --data-dir /var/lib/warzone +./warzone-server --bind 0.0.0.0:7700 --data-dir ./data + +# With federation enabled +./warzone-server --federation federation.json ``` ### CLI Flags @@ -39,214 +65,339 @@ Rust 1.75 or later (set via `rust-version = "1.75"` in `Cargo.toml`). | Flag | Short | Default | Description | |------|-------|---------|-------------| | `--bind` | `-b` | `0.0.0.0:7700` | Address and port to listen on | -| `--data-dir` | `-d` | `./warzone-data` | Directory for sled database files | +| `--data-dir` | `-d` | `./warzone-data` | Directory for the sled database | +| `--federation` | `-f` | *(none)* | Path to federation JSON config file | -### Logging +### Environment Variables -The server uses `tracing-subscriber`. Control log level with the `RUST_LOG` -environment variable: +| Variable | Default | Description | +|----------|---------|-------------| +| `RUST_LOG` | `warn` (production) | Log filter. Examples: `info`, `warzone_server=debug`, `trace` | +| `WZP_RELAY_ADDR` | *(none)* | WZP voice relay address advertised to clients | + +### systemd Service + +A production-ready unit file is provided at `deploy/warzone-server.service`: + +```ini +[Unit] +Description=Warzone Messenger Server (featherChat) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=warzone +Group=warzone +WorkingDirectory=/home/warzone +ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json +Restart=always +RestartSec=3 +LimitNOFILE=65536 + +# Security hardening +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/warzone/data +PrivateTmp=yes + +Environment=RUST_LOG=warn,warzone_server::federation=info + +[Install] +WantedBy=multi-user.target +``` + +Install and enable: ```bash -RUST_LOG=info ./warzone-server -RUST_LOG=warzone_server=debug ./warzone-server -RUST_LOG=trace ./warzone-server # very verbose +sudo cp deploy/warzone-server.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now warzone-server ``` --- -## 3. API Reference +## 3. Configuration -All API endpoints are under the `/v1` prefix. The web UI is served at `/`. +### Federation JSON -### Health Check +Enable federation by passing `--federation ` on startup. The config +file specifies the local server identity, peer connection details, and a +shared secret for authentication. -``` -GET /v1/health -``` +**Format** (see `federation.example.json`): -**Response:** ```json { - "status": "ok", - "version": "0.1.0" + "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 } ``` -Use this for monitoring, load balancer health probes, and uptime checks. +| Field | Description | +|-------|-------------| +| `server_id` | Unique name for this server (e.g. `"alpha"`) | +| `shared_secret` | Pre-shared secret; must match on both sides | +| `peer.id` | The remote server's `server_id` | +| `peer.url` | HTTP base URL of the remote server | +| `presence_interval_secs` | How often to broadcast online-user lists (default 5) | --- -### Register Key Bundle +## 4. Federation -``` -POST /v1/keys/register -Content-Type: application/json -``` +Federation connects two Warzone servers over a persistent WebSocket so +their users can communicate transparently. + +### How It Works + +- On startup, each server opens an outgoing WebSocket to its peer at + `/v1/federation/ws` and authenticates with the shared secret. +- The connection auto-reconnects on failure. +- Presence (online fingerprints) is synced on the configured interval. +- Messages to users on the remote server are forwarded automatically. + +### Federated Features + +| Feature | Behavior | +|---------|----------| +| **Key lookup proxy** | If a key bundle is not found locally, the server queries the peer | +| **Message forwarding** | Messages addressed to a remote fingerprint are relayed over the WS | +| **Alias resolution** | `/v1/resolve/:address` checks the peer if the alias is not local | +| **Presence sync** | Each server broadcasts its online fingerprints to the peer | + +### Two-Server Setup + +**Server A** (`alpha`, e.g. `mequ`): -**Request body:** ```json { - "fingerprint": "a3f8:c912:44be:7d01", - "bundle": [/* bincode-serialized PreKeyBundle as byte array */] + "server_id": "alpha", + "shared_secret": "s3cret-shared-between-both", + "peer": { "id": "bravo", "url": "http://bravo-host:7700" }, + "presence_interval_secs": 5 } ``` -The `bundle` field is a JSON array of unsigned bytes (the raw bincode -serialization of a `PreKeyBundle`). +**Server B** (`bravo`, e.g. `kh3rad3ree`): -**Response:** ```json { - "ok": true + "server_id": "bravo", + "shared_secret": "s3cret-shared-between-both", + "peer": { "id": "alpha", "url": "http://alpha-host:7700" }, + "presence_interval_secs": 5 } ``` -**Behavior:** stores the bundle in the `keys` sled tree, keyed by the -fingerprint string. Overwrites any existing bundle for the same fingerprint. +Both files use the same `shared_secret`. Each server's `peer.id` matches +the other server's `server_id`. + +### Federation Status Endpoint + +```bash +curl http://localhost:7700/v1/federation/status +``` + +Returns JSON with connection state, peer info, and presence data. --- -### Fetch Key Bundle +## 5. API Reference -``` -GET /v1/keys/{fingerprint} -``` +All endpoints are prefixed with `/v1`. The web UI is served at `/`. -**Path parameter:** the fingerprint string, e.g. `a3f8:c912:44be:7d01`. +### Notation -**Response (200):** -```json -{ - "fingerprint": "a3f8:c912:44be:7d01", - "bundle": "base64-encoded-bincode-bytes..." -} -``` - -The `bundle` value is standard base64-encoded bincode. The client decodes -base64, then deserializes with bincode to recover the `PreKeyBundle`. - -**Response (404):** returned if no bundle is registered for the fingerprint. +- **Auth** = requires `Authorization: Bearer ` header (write routes). +- **Public** = no authentication needed (read routes). --- -### Send Message +### Health -``` -POST /v1/messages/send -Content-Type: application/json -``` - -**Request body:** -```json -{ - "to": "b7d1:e845:0022:9f3a", - "message": [/* bincode-serialized WireMessage as byte array */] -} -``` - -**Response:** -```json -{ - "ok": true -} -``` - -**Behavior:** the message bytes are stored in the `messages` sled tree under -the key `queue:{recipient_fingerprint}:{uuid}`. The UUID is generated -server-side to ensure unique keys. - -The server does NOT parse, validate, or inspect the message contents. It is an -opaque blob. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/health` | Public | Health check; returns `{"status":"ok"}` | --- -### Poll Messages +### Keys -``` -GET /v1/messages/poll/{fingerprint} -``` - -**Response (200):** -```json -[ - "base64-encoded-message-1", - "base64-encoded-message-2" -] -``` - -Returns a JSON array of base64-encoded message blobs. Each blob is a -bincode-serialized `WireMessage`. An empty array means no messages. - -**Behavior:** scans the `messages` sled tree for all keys prefixed with -`queue:{fingerprint}`. Messages are NOT deleted by polling; they remain until -explicitly acknowledged. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/keys/register` | Public | Register a pre-key bundle | +| POST | `/v1/keys/replenish` | Public | Upload additional one-time pre-keys | +| GET | `/v1/keys/:fingerprint` | Public | Fetch a key bundle (falls back to federation peer) | +| GET | `/v1/keys/list` | Public | List all registered fingerprints | +| GET | `/v1/keys/:fingerprint/otpk-count` | Public | Remaining one-time pre-key count | +| GET | `/v1/keys/:fingerprint/devices` | Public | List devices for a fingerprint | --- -### Acknowledge Message +### Messages -``` -DELETE /v1/messages/{id}/ack -``` - -**Path parameter:** the message storage key (currently the full sled key -including the `queue:` prefix and UUID). - -**Response:** -```json -{ - "ok": true -} -``` - -**Behavior:** removes the message from the `messages` tree. - -**Note:** the current implementation requires knowing the exact sled key to -acknowledge. A proper message-ID-based index is planned for Phase 2. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/messages/send` | Auth | Send an encrypted message blob | +| GET | `/v1/messages/poll/:fingerprint` | Public | Poll queued messages | +| DELETE | `/v1/messages/:id/ack` | Public | Acknowledge (delete) a message | --- -## 4. Web UI +### Groups -The server serves a single-page web client at the root path `/`. - -``` -GET / -``` - -Returns an HTML page with embedded CSS and JavaScript. The web client provides: - -- **Identity generation:** generates a random 32-byte seed in the browser - using `crypto.getRandomValues()`. -- **Identity recovery:** paste a hex-encoded seed to recover. -- **Fingerprint display:** shows the user's fingerprint in the header. -- **Key registration:** automatically registers a public key with the server - on entry. -- **Message polling:** polls `/v1/messages/poll/{fingerprint}` every 5 seconds. -- **Slash commands:** `/help`, `/info`, `/seed`. - -### Web Client Limitations - -- Uses ECDH P-256 (Web Crypto API) instead of X25519. Cross-client - compatibility with the CLI is not yet implemented. (Phase 2) -- Does not use BIP39 mnemonics; seed is displayed as hex. -- Message decryption is not yet wired (Double Ratchet in JS is TODO). -- The seed is stored in `localStorage` (unencrypted). +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/groups/create` | Auth | Create a group | +| POST | `/v1/groups/:name/join` | Auth | Join a group | +| POST | `/v1/groups/:name/send` | Auth | Send a message to a group | +| POST | `/v1/groups/:name/leave` | Auth | Leave a group | +| POST | `/v1/groups/:name/kick` | Auth | Kick a member from a group | +| GET | `/v1/groups` | Public | List all groups | +| GET | `/v1/groups/:name` | Public | Get group details | +| GET | `/v1/groups/:name/members` | Public | List members (includes online status) | --- -## 5. Database +### Aliases -The server uses **sled** (embedded key-value store). All data lives under the -directory specified by `--data-dir`. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/alias/register` | Auth | Register a human-readable alias | +| POST | `/v1/alias/unregister` | Auth | Remove your alias | +| POST | `/v1/alias/recover` | Auth | Transfer alias to a new fingerprint | +| POST | `/v1/alias/renew` | Auth | Renew alias expiry | +| POST | `/v1/alias/admin-remove` | Auth | Admin-remove an alias | +| GET | `/v1/alias/resolve/:name` | Public | Resolve alias to fingerprint | +| GET | `/v1/alias/list` | Public | List all registered aliases | +| GET | `/v1/alias/whois/:fingerprint` | Public | Reverse-lookup: fingerprint to alias | -### Trees (Tables) +--- -| Tree | Key format | Value | Purpose | -|------|-----------|-------|---------| -| `keys` | fingerprint string (UTF-8 bytes) | bincode `PreKeyBundle` | Pre-key bundle storage | -| `messages` | `queue:{fingerprint}:{uuid}` (UTF-8 bytes) | bincode `WireMessage` | Message queue | -| `otpks` | (reserved) | (reserved) | One-time pre-key tracking (not yet used server-side) | +### Calls (WZP) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/calls/initiate` | Auth | Start a 1:1 call | +| POST | `/v1/calls/:id/end` | Auth | End an active call | +| POST | `/v1/calls/missed` | Auth | Get missed calls for a fingerprint | +| POST | `/v1/groups/:name/call` | Auth | Initiate a group call | +| GET | `/v1/calls/:id` | Public | Get call details | +| GET | `/v1/calls/active` | Public | List active calls | + +--- + +### Devices + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/devices/:id/kick` | Auth | Disconnect a specific device | +| POST | `/v1/devices/revoke-all` | Auth | Disconnect all devices (optional keep one) | +| GET | `/v1/devices` | Auth | List your connected devices | + +--- + +### Presence + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/presence/batch` | Auth | Batch-query presence for multiple fingerprints | +| GET | `/v1/presence/:fingerprint` | Public | Check if a fingerprint is online | + +--- + +### Friends + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/friends` | Auth | Save friend list (encrypted blob) | +| GET | `/v1/friends` | Auth | Retrieve saved friend list | + +--- + +### Resolve + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/resolve/:address` | Public | Universal resolve: ETH address, alias, or fingerprint. Checks federation peer if not found locally. | + +--- + +### WZP Voice Relay + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/wzp/relay-config` | Public | Get the WZP relay address for voice calls | + +--- + +### Federation + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/federation/status` | Public | Federation connection status and peer info | +| GET | `/v1/federation/ws` | Internal | WebSocket endpoint for server-to-server communication | + +--- + +### Bot API + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/bot/register` | Auth | Register a bot; returns an API token | +| GET | `/v1/bot/:token/getMe` | Token | Bot identity info | +| POST | `/v1/bot/:token/getUpdates` | Token | Long-poll for new messages (Telegram-compatible) | +| POST | `/v1/bot/:token/sendMessage` | Token | Send a message as the bot (Telegram-compatible) | + +Bot tokens are scoped to the bot's fingerprint. The `getUpdates` and +`sendMessage` endpoints follow the Telegram Bot API conventions so existing +Telegram bot libraries can be adapted with minimal changes. + +--- + +### WebSocket + +| Path | Description | +|------|-------------| +| `/v1/ws/:fingerprint` | Real-time message delivery. Clients receive instant push of new messages. | + +--- + +### Web UI + +| Path | Description | +|------|-------------| +| `/` | Single-page WASM web client | +| `/wasm/warzone_wasm.js` | WASM JavaScript bindings | +| `/wasm/warzone_wasm_bg.wasm` | WASM binary | + +--- + +## 6. Database + +The server uses **sled** (embedded key-value store). All data lives under +the `--data-dir` directory. + +### Trees + +| Tree | Purpose | +|------|---------| +| `keys` | Pre-key bundles (public keys only) | +| `messages` | Queued encrypted message blobs | +| `groups` | Group metadata and membership | +| `aliases` | Human-readable alias mappings | +| `tokens` | Authentication tokens (device sessions) | +| `calls` | Call records (1:1 and group) | +| `missed_calls` | Missed call notifications | +| `friends` | Encrypted friend lists | +| `eth_addresses` | Ethereum address to fingerprint mappings | ### Data Directory Structure @@ -254,161 +405,123 @@ directory specified by `--data-dir`. warzone-data/ db # sled database file conf # sled config - blobs/ # sled blob storage (if any) + blobs/ # sled blob storage snap.*/ # sled snapshots ``` -The exact file layout is managed by sled internally. The entire directory -should be treated as a unit for backup. - -### What the Server Stores - -- **Pre-key bundles:** public keys only. The server never holds private keys. -- **Encrypted message blobs:** opaque binary data. The server cannot read - message contents. -- **Metadata visible to server:** sender fingerprint, recipient fingerprint, - message size, timestamps (implicit from storage order). +The entire directory should be treated as a unit for backup. Stop the server +before copying, or use filesystem-level snapshots (LVM, ZFS, btrfs). --- -## 6. Deployment +## 7. Security -### Single Binary +### Auth Middleware -The recommended deployment is a single `warzone-server` binary behind a -reverse proxy for TLS termination. +All write (POST) endpoints require a bearer token in the `Authorization` +header. Tokens are issued during key registration and tied to a fingerprint. +Read (GET) endpoints are public. -### Reverse Proxy (nginx) +### Rate Limiting -```nginx -server { - listen 443 ssl http2; - server_name wz.example.com; +- **200 concurrent requests** (tower `ConcurrencyLimitLayer`) +- **5 WebSocket connections per fingerprint** (multi-device cap) - ssl_certificate /etc/letsencrypt/live/wz.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/wz.example.com/privkey.pem; +### Device Management - location / { - proxy_pass http://127.0.0.1:7700; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; +Users can list connected devices, kick individual devices, or revoke all +sessions via the `/v1/devices` endpoints. The `revoke-all` endpoint accepts +an optional `keep_device_id` to keep the current device active. - # WebSocket support (for future real-time push) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } -} -``` +### What the Server Can See -When using a reverse proxy, bind the server to localhost only: +| Data | Visible | +|------|---------| +| Message plaintext | No (E2E encrypted blobs) | +| Sender/recipient fingerprints | Yes | +| Message size and timing | Yes | +| Public pre-key bundles | Yes (public by design) | +| IP addresses | Yes (from HTTP) | + +--- + +## 8. Monitoring + +### Logging + +Control verbosity with `RUST_LOG`: ```bash -./warzone-server --bind 127.0.0.1:7700 +RUST_LOG=warn ./warzone-server # production default +RUST_LOG=info ./warzone-server # request-level logging +RUST_LOG=warzone_server=debug ./warzone-server # server internals +RUST_LOG=trace ./warzone-server # everything ``` -### systemd Service +With systemd: -```ini -[Unit] -Description=Warzone Messenger Server -After=network.target - -[Service] -Type=simple -User=warzone -ExecStart=/usr/local/bin/warzone-server --bind 127.0.0.1:7700 --data-dir /var/lib/warzone -Restart=always -RestartSec=5 -Environment=RUST_LOG=info - -[Install] -WantedBy=multi-user.target +```bash +journalctl -u warzone-server -f ``` ---- - -## 7. Monitoring - -### Health Endpoint +### Health Check ```bash curl http://localhost:7700/v1/health -# {"status":"ok","version":"0.1.0"} ``` -Use this for: -- Load balancer health checks -- Uptime monitoring (e.g., with `uptime-kuma`, Prometheus blackbox exporter) -- Deployment verification +### Federation Status -### Logs +```bash +curl http://localhost:7700/v1/federation/status +``` -All request activity is logged via `tracing`. In production, pipe to a log -aggregator or use `journalctl -u warzone-server`. +Returns connection state, peer identity, and synced presence data. --- -## 8. Security Considerations +## 9. Deploy Scripts -### The Server Is a Dumb Relay +The `scripts/build-linux.sh` script handles the full build and deploy +lifecycle via Hetzner Cloud VMs. -The server never sees plaintext message content. It stores and forwards -opaque encrypted blobs. Even if the server is fully compromised, an attacker -gains: +### Key Commands -- **Encrypted message blobs** (useless without recipient's private keys) -- **Public pre-key bundles** (public by design) -- **Metadata:** who is messaging whom, when, and how often +| Command | Description | +|---------|-------------| +| `--ship` | Full pipeline: build on VM, deploy to all production servers, destroy VM | +| `--update-all` | Upload pre-built binaries to all production servers and restart | +| `--update ` | Update a single production server | +| `--status` | Check service status and federation on all production servers | +| `--logs [user@host]` | Tail `journalctl` logs (defaults to first production server) | -### What the Server CAN See +### Typical Deploy Workflow -| Data | Visible to server | -|------|-------------------| -| Message plaintext | No | -| Sender fingerprint | Yes (in `WireMessage`) | -| Recipient fingerprint | Yes (used for routing) | -| Message size | Yes | -| Timing | Yes | -| IP addresses | Yes (from HTTP) | -| Pre-key bundles (public keys) | Yes | +```bash +# One command: build, deploy everywhere, clean up +./scripts/build-linux.sh --ship -### Mitigations for Metadata (Future) - -- **Sealed sender** (Phase 6): hide sender identity from the server. -- **Padding:** fixed-size messages to prevent size-based analysis. -- **Onion routing** (Phase 6): hide IP addresses via relay chains. - -### Access Control - -The current server has **no authentication**. Anyone can: -- Register a key bundle for any fingerprint -- Poll messages for any fingerprint -- Send messages to any fingerprint - -**TODO (Phase 2):** authentication via Ed25519 challenge-response. Clients -sign requests to prove they own the fingerprint they claim. +# Or step by step: +./scripts/build-linux.sh --all # build (VM persists) +./scripts/build-linux.sh --update-all # deploy binaries +./scripts/build-linux.sh --destroy # clean up VM +./scripts/build-linux.sh --status # verify +``` --- -## 9. Backup and Recovery +## 10. Backup and Recovery -### Database Backup - -The sled database can be backed up by copying the entire data directory while -the server is stopped: +### Backup ```bash systemctl stop warzone-server -cp -r /var/lib/warzone /backup/warzone-$(date +%Y%m%d) +cp -r /home/warzone/data /backup/warzone-$(date +%Y%m%d) systemctl start warzone-server ``` -**Warning:** copying the sled directory while the server is running may -produce an inconsistent snapshot. Stop the server first or use filesystem-level -snapshots (LVM, ZFS, btrfs). +Do not copy the sled directory while the server is running without +filesystem-level snapshots. ### Recovery @@ -416,14 +529,5 @@ snapshots (LVM, ZFS, btrfs). 2. Replace the data directory with the backup. 3. Start the server. -Messages queued after the backup was taken will be lost. Since all messages -are E2E encrypted, there is no way to recover them from any other source. - -### Data Loss Impact - -- **Lost key bundles:** users must re-register. No security impact (public - data). -- **Lost message queue:** undelivered messages are permanently lost. Senders - will not know delivery failed (no delivery receipts yet). -- **Corrupted database:** sled includes crash recovery. If the database is - corrupt beyond recovery, delete it and start fresh. Users re-register. +Messages queued after the backup was taken are permanently lost. All +messages are E2E encrypted and cannot be recovered from any other source. diff --git a/warzone/docs/USAGE.md b/warzone/docs/USAGE.md index 6917315..c872424 100644 --- a/warzone/docs/USAGE.md +++ b/warzone/docs/USAGE.md @@ -1,6 +1,6 @@ -# Warzone Messenger (featherChat) — Usage Guide +# featherChat Usage Guide -**Version:** 0.0.20 +**Version:** 0.0.21 --- @@ -11,540 +11,390 @@ Requirements: Rust 1.75+, cargo ```bash -# Clone the repository git clone cd warzone -# Build all binaries cargo build --release -# Binaries are in target/release/ -# warzone-server — server -# warzone-client — CLI/TUI client +# Binaries output to target/release/: +# warzone-server — relay server (with embedded web client) +# warzone-client — CLI / TUI client ``` -### Build the WASM Module (Web Client) +### WASM Build (Web Client) Requirements: wasm-pack ```bash cd crates/warzone-wasm wasm-pack build --target web -# Output in pkg/ — copy to web client directory +# Output in pkg/ — copy to the web client directory +``` + +### Linux Cross-Compile + +The `scripts/build-linux.sh` script builds Linux x86_64 binaries on a Hetzner Cloud VPS. + +```bash +# Full pipeline: create VM, build, download binaries +./scripts/build-linux.sh --all + +# Or step by step: +./scripts/build-linux.sh --prepare # Create VM, install deps, upload source +./scripts/build-linux.sh --build # Build release binaries on the VM +./scripts/build-linux.sh --transfer # Download binaries to target/linux-x86_64/ +./scripts/build-linux.sh --destroy # Delete the VM + +# One-command ship to all production servers: +./scripts/build-linux.sh --ship # prepare + build + transfer + deploy + destroy ``` ### Server Configuration -The server accepts two flags: - ```bash warzone-server --bind 0.0.0.0:7700 --data-dir ./warzone-data ``` -| Flag | Default | Description | -|--------------|-------------------|-----------------------| -| `--bind` | `0.0.0.0:7700` | Listen address | -| `--data-dir` | `./warzone-data` | sled database path | +| Flag | Default | Description | +|--------------|------------------|--------------------| +| `--bind` | `0.0.0.0:7700` | Listen address | +| `--data-dir` | `./warzone-data` | sled database path | Environment variables: -| Variable | Default | Description | -|-------------------------|----------|----------------------------| -| `WARZONE_ADMIN_PASSWORD`| `admin` | Password for admin alias ops| -| `RUST_LOG` | `info` | Log level filter | +| Variable | Default | Description | +|--------------------------|---------|------------------------------| +| `WARZONE_ADMIN_PASSWORD` | `admin` | Password for admin alias ops | +| `RUST_LOG` | `info` | Log level filter | --- -## Quick Start +## Identity -### CLI Quick Start +### Generate a New Identity ```bash -# 1. Generate a new identity -warzone init -# → Prompts for passphrase -# → Displays fingerprint and 24-word mnemonic -# → SAVE THE MNEMONIC — it is your identity - -# 2. Register with a server -warzone register --server http://your-server:7700 - -# 3. Show your identity -warzone info -# Fingerprint: a3f8:c912:44be:7d01:... -# Signing key: ... -# Encryption key: ... - -# 4. Send a message -warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://your-server:7700 - -# 5. Receive messages -warzone recv --server http://your-server:7700 - -# 6. Launch interactive TUI -warzone chat --server http://your-server:7700 -warzone chat a3f8:c912:44be:7d01:... --server http://your-server:7700 -warzone chat @alice --server http://your-server:7700 -``` - -### Web Client Quick Start - -1. Navigate to the server URL in a browser (e.g., `http://your-server:7700`) -2. The web client generates a new identity automatically on first visit -3. Your seed is stored in `localStorage` — back it up via the displayed hex seed -4. Use `/peer ` or `/peer @alias` to select a chat partner -5. Type messages and press Enter to send - ---- - -## CLI Commands - -### `warzone init` - -Generate a new identity (seed + keypair + pre-keys). - -```bash -$ warzone init -Set passphrase (empty for no encryption): **** -Confirm passphrase: **** - -Your identity: - Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 - Mnemonic: abandon ability able about above absent absorb abstract ... - -SAVE YOUR MNEMONIC — it is the ONLY way to recover your identity. +warzone-client init +# Prompts for passphrase +# Displays fingerprint + 24-word BIP39 mnemonic +# SAVE THE MNEMONIC — it is the only way to recover your identity ``` The seed is stored at `~/.warzone/identity.seed`, encrypted with Argon2id + ChaCha20-Poly1305. -### `warzone recover ` - -Recover an identity from a BIP39 mnemonic. +### Recover from Mnemonic ```bash -$ warzone recover abandon ability able about above absent absorb abstract ... -Set passphrase (empty for no encryption): **** -Confirm passphrase: **** -Identity recovered. Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 +warzone-client recover abandon ability able about above absent absorb abstract ... +# Prompts for passphrase, restores the same identity ``` -### `warzone info` - -Display your fingerprint and public keys. +### View Your Identity ```bash -$ warzone info -Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 -Signing key: 3a7b... -Encryption key: 9f2c... +warzone-client info +# Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 ``` -### `warzone eth` +In the TUI, use `/info` to display your fingerprint, `/seed` to display your 24-word recovery mnemonic, and `/eth` to display your Ethereum address. -Display your Ethereum-compatible address derived from the same seed. +### Ethereum Address + +Your ETH address is derived from the same seed via domain-separated HKDF. One seed, dual identity. ```bash -$ warzone eth -Warzone fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 -Ethereum address: 0x71C7656EC7ab88b098defB751B7401B5f6d8976F -Same seed, dual identity. +warzone-client eth +# Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 +# Ethereum: 0x71C7656EC7ab88b098defB751B7401B5f6d8976F ``` -### `warzone register` +### Addressing -Register your pre-key bundle with a server. +featherChat supports three addressing modes. All three work anywhere a peer address is accepted: -```bash -$ warzone register --server http://localhost:7700 -``` - -### `warzone send ` - -Send an encrypted message. Recipient can be a fingerprint or `@alias`. - -```bash -$ warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://localhost:7700 -$ warzone send @alice "Hello!" --server http://localhost:7700 -``` - -### `warzone recv` - -Poll the server for messages and decrypt them. - -```bash -$ warzone recv --server http://localhost:7700 -``` - -### `warzone chat [peer]` - -Launch the interactive TUI client. - -```bash -$ warzone chat --server http://localhost:7700 -$ warzone chat @alice --server http://localhost:7700 -$ warzone chat a3f8:c912:... --server http://localhost:7700 -``` - -### `warzone backup [output]` - -Export an encrypted backup of local data (sessions, pre-keys). - -```bash -$ warzone backup my-backup.wzb -Backup saved to my-backup.wzb (4096 bytes encrypted) -``` - -The backup is encrypted with your seed via HKDF(info="warzone-history") + ChaCha20-Poly1305. - -### `warzone restore ` - -Restore from an encrypted backup. Requires the same seed. - -```bash -$ warzone restore my-backup.wzb -Restored 12 entries from my-backup.wzb -``` +| Format | Example | Description | +|--------|---------|-------------| +| Fingerprint | `a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4` | SHA-256 of Ed25519 pubkey, 8 groups of 4 hex digits | +| Alias | `@alice` | Human-readable, server-resolved | +| ETH address | `0x71C7...976F` | Ethereum address derived from the same seed | --- -## TUI Commands +## TUI Client -The TUI is launched with `warzone chat`. All commands start with `/`. +Launch the interactive terminal UI: -### Peer & Navigation +```bash +warzone-client chat --server http://your-server:7700 +warzone-client chat @alice --server http://your-server:7700 +warzone-client chat a3f8:c912:... --server http://your-server:7700 +``` -| Command | Short | Description | -|------------------------|-------|----------------------------------------------| -| `/peer ` | `/p` | Set the active peer (fingerprint or @alias) | -| `/dm` | | Switch to DM mode (clear group context) | -| `/r` or `/reply` | | Switch to last person who DM'd you | -| `/info` | | Display your fingerprint | -| `/eth` | | Display your Ethereum address | -| `/quit` | `/q` | Exit the TUI | +### Complete Command Reference + +#### Peer and Navigation + +| Command | Description | +|---------|-------------| +| `/peer ` | Set DM peer by fingerprint | +| `/p @alias` | Set DM peer by alias (short form of `/peer`) | +| `/peer 0x...` | Set DM peer by ETH address | +| `/r [message]` | Reply to last DM sender; optionally include an inline message | +| `/dm` | Switch to DM mode (clear group context) | ``` /peer a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 /p @alice +/peer 0x71C7656EC7ab88b098defB751B7401B5f6d8976F /r +/r hey, got your message /dm ``` -### Alias Management +#### Groups -| Command | Description | -|-----------------------|--------------------------------------------| -| `/alias ` | Register an alias for your fingerprint | -| `/unalias` | Remove your alias | -| `/aliases` | List all registered aliases | - -``` -/alias alice -/unalias -/aliases -``` - -When you register an alias, the server returns a **recovery key** (32 hex chars). Save it — it is the only way to reclaim the alias if you lose access to your identity. - -### Contacts & History - -| Command | Short | Description | -|------------------------|-------|------------------------------------------| -| `/contacts` | `/c` | List all contacts with message counts | -| `/history [peer]` | `/h` | Show message history (last 50 messages) | - -``` -/contacts -/c -/history a3f8c91244be7d01 -/h -``` - -If a peer is already set, `/h` without arguments shows that peer's history. - -### Group Commands - -| Command | Description | -|------------------------|------------------------------------------| -| `/g ` | Switch to group (auto-join) | -| `/gcreate ` | Create a new group | -| `/gjoin ` | Join an existing group | -| `/gleave` | Leave the current group | -| `/gkick ` | Kick a member (creator only) | -| `/gmembers` | List members of the current group | -| `/glist` | List all groups on the server | +| Command | Description | +|---------|-------------| +| `/g ` | Switch to group (auto-joins if not a member) | +| `/gcreate ` | Create a new group (you become creator) | +| `/gjoin ` | Join an existing group | +| `/gleave` | Leave the current group | +| `/gkick ` | Kick a member (creator only) | +| `/gmembers` | List members of the current group with online status | +| `/glist` | List all groups on the server | ``` /gcreate ops-team /g ops-team -/gjoin ops-team /gmembers -/gkick a3f8c91244be7d01 +/gkick @mallory /gleave /glist ``` -Group messages are prefixed with `#groupname` in the UI. The current target shows in the header bar. +When in a group, the header bar shows `#groupname` and all messages are sent to that group. -### File Transfer +#### Alias Management -| Command | Description | -|-------------------|----------------------------------------------| -| `/file ` | Send a file to the current peer or group | +| Command | Description | +|---------|-------------| +| `/alias ` | Register an alias for your fingerprint | +| `/aliases` | List all registered aliases | +| `/unalias` | Remove your alias | + +Alias rules: 1-32 alphanumeric characters (plus `_` and `-`), case-insensitive, normalized to lowercase. TTL is 365 days of inactivity, with a 30-day grace period. Registration returns a recovery key — save it. + +#### File Transfer + +| Command | Description | +|---------|-------------| +| `/file ` | Send a file to the current peer or group | ``` /file /path/to/document.pdf /file ./photo.jpg ``` -Constraints: -- Maximum file size: 10 MB -- Chunk size: 64 KB -- Files are sent as `FileHeader` + `FileChunk` wire messages -- SHA-256 verification on receipt -- Received files are saved to the current directory +Files are split into 64 KB chunks, each encrypted with the Double Ratchet session key. The recipient reassembles and verifies a SHA-256 hash over the complete file. Maximum file size is 10 MB. Received files are saved to the current directory. -### Input Editing +#### Contacts and History -The TUI supports full readline-style editing: +| Command | Description | +|---------|-------------| +| `/contacts` or `/c` | List all contacts with message counts | +| `/history` or `/h` | Show message history for current peer (last 50) | +| `/history ` | Show history for a specific peer | -| Key | Action | -|-----------------|------------------------------| -| Left/Right | Move cursor | -| Home / Ctrl-A | Move to beginning of line | -| End / Ctrl-E | Move to end of line | -| Backspace | Delete character before cursor| -| Delete | Delete character at cursor | -| Ctrl-U | Clear entire input | -| Ctrl-W | Delete word before cursor | -| Enter | Send message / execute command| -| Ctrl-C | Quit | +#### Identity and Security + +| Command | Description | +|---------|-------------| +| `/info` | Show your fingerprint | +| `/eth` | Show your Ethereum address | +| `/seed` | Show your 24-word recovery mnemonic | +| `/devices` | List your active device sessions | +| `/kick ` | Revoke a specific device session | + +#### Friend List + +| Command | Description | +|---------|-------------| +| `/friend` | List friends with online/offline status | +| `/friend
` | Add a friend by fingerprint or alias | +| `/unfriend
` | Remove a friend | + +The friend list is end-to-end encrypted and stored on the server as an opaque blob. The server cannot read it. Presence status (online/offline) is shown next to each friend. + +#### General + +| Command | Description | +|---------|-------------| +| `/help` or `/?` | Show command list | +| `/quit` or `/q` | Exit the TUI | + +### Keyboard Navigation + +| Key | Action | +|-----|--------| +| PageUp / PageDown | Scroll messages by 10 | +| Up / Down (when input is empty) | Scroll messages by 1 | +| Ctrl+End | Snap scroll to bottom | +| Left / Right | Move cursor in input | +| Home / Ctrl-A | Beginning of line | +| End / Ctrl-E | End of line | +| Ctrl-U | Clear input | +| Ctrl-W | Delete word before cursor | +| Ctrl-C | Quit | ### Receipt Indicators -Sent messages display receipt status: - -| Indicator | Meaning | -|-----------|----------------------------| -| (tick) | Sent (no confirmation yet) | -| (double tick, gray) | Delivered (decrypted by recipient) | -| (double tick, blue) | Read (viewed by recipient) | +| Indicator | Meaning | +|-----------|---------| +| Single tick | Sent (no confirmation yet) | +| Double tick (gray) | Delivered (decrypted by recipient) | +| Double tick (blue) | Read (viewed by recipient) | --- -## Web Client Commands +## Web Client -The web client supports the same commands as the TUI, plus additional web-specific commands: +### Access -### Standard Commands (same as TUI) +Navigate to the server URL in a browser (e.g., `http://your-server:7700`). The web client generates a new identity automatically on first visit. Your seed is stored in `localStorage` — back it up using `/seed`. -`/peer`, `/p`, `/alias`, `/unalias`, `/r`, `/reply`, `/contacts`, `/c`, -`/history`, `/h`, `/g`, `/gcreate`, `/gjoin`, `/gleave`, `/gkick`, `/gmembers`, -`/glist`, `/file`, `/eth`, `/info`, `/quit`, `/dm` +The web client uses the same E2E encryption as the TUI, compiled to WASM. -### Alias Resolution +### URL Deep Links -Both TUI and web support `@alias` syntax: +The web client supports deep links for direct navigation: -``` -/peer @alice # Resolves alias to fingerprint -/p @bob # Short form -``` +| URL | Effect | +|-----|--------| +| `/message/@alice` | Opens a DM with the alias `@alice` | +| `/message/0xABC...` | Opens a DM with an ETH address | +| `/group/#ops` | Opens the group `#ops` | -### Web-Only Commands +Share these links to let someone jump straight into a conversation. -| Command | Description | -|-------------------|----------------------------------------------------| -| `/selftest` | Run WASM crypto self-test (X3DH + ratchet cycle) | -| `/bundleinfo` | Debug: show bundle details (keys, sizes) | -| `/debug` | Toggle debug mode (verbose output) | -| `/reset` | Clear identity and all local data | -| `/install` | Show PWA installation instructions | -| `/sessions` | List active ratchet sessions | -| `/admin-unalias` | Admin: remove any alias (requires admin password) | +### Clickable Addresses -### Web Client Storage +Fingerprints and addresses displayed in messages are clickable. Clicking an address sets it as your DM peer. If you are currently typing, clicking copies the address instead. -The web client stores data in `localStorage`: +### Supported Commands -| Key | Value | Purpose | -|----------------------|--------------------------------|----------------------------| -| `wz_seed` | hex seed (64 chars) | Identity seed | -| `wz_spk_secret` | hex SPK secret (64 chars) | Signed pre-key secret | -| `wz_session:` | base64 ratchet state | Per-peer session | -| `wz_contacts` | JSON contact list | Contact metadata | +The web client supports the same slash commands as the TUI: `/peer`, `/p`, `/r`, `/dm`, `/g`, `/gcreate`, `/gjoin`, `/gleave`, `/gkick`, `/gmembers`, `/glist`, `/alias`, `/aliases`, `/unalias`, `/file`, `/contacts`, `/c`, `/history`, `/h`, `/info`, `/eth`, `/seed`, `/friend`, `/unfriend`, `/devices`, `/kick`, `/help`, `/quit`. --- -## Identity Management +## Groups -### Seed - -Your identity is a 32-byte seed. All keys are deterministically derived from it. **Lose the seed = lose the identity forever.** - -### Mnemonic Backup - -The seed is displayed as a 24-word BIP39 mnemonic during `warzone init`. Write it down on paper and store securely. You can recover your full identity from the mnemonic using `warzone recover`. - -### Passphrase Encryption - -The seed file (`~/.warzone/identity.seed`) is encrypted at rest: +### Creating and Using Groups ``` -File format: WZS1(4 bytes) + salt(16) + nonce(12) + ciphertext(48) - -Encryption: Argon2id(passphrase, salt) → 32-byte key - ChaCha20-Poly1305(key, nonce, seed) → ciphertext +/gcreate ops-team # Create (you become creator) +/g ops-team # Switch to group (auto-joins if needed) +/gjoin ops-team # Explicitly join an existing group ``` -An empty passphrase stores the seed in plaintext (for testing only). - -### Ethereum Address - -Your Ethereum address is derived from the same seed with domain-separated HKDF. Use `warzone eth` or `/eth` in the TUI to display it. - -### Fingerprint Format - -Fingerprints are `SHA-256(Ed25519_pubkey)[:16]` displayed as 8 groups of 4 hex digits: - -``` -a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 -``` - ---- - -## Alias System - -Aliases provide human-readable names for fingerprints. - -### Registration - -``` -/alias alice -``` - -Returns a **recovery key** — save it securely. One alias per fingerprint. One fingerprint per alias. - -### Rules - -- Aliases are 1-32 alphanumeric characters (plus `_` and `-`) -- Case-insensitive, normalized to lowercase -- TTL: 365 days of inactivity (auto-renewed on any message activity) -- Grace period: 30 days after expiry before reclamation -- Recovery key: allows reclaiming an expired alias - -### Recovery - -If you lose access to your identity but have the recovery key, the server provides an alias recovery endpoint. This is an HTTP API operation: - -``` -POST /v1/alias/recover -{ - "alias": "alice", - "recovery_key": "a1b2c3...", - "new_fingerprint": "new_fp_hex" -} -``` - -The recovery key is rotated on each recovery. - -### Admin Operations - -An admin (with `WARZONE_ADMIN_PASSWORD`) can remove any alias: - -``` -POST /v1/alias/admin-remove -{ - "alias": "alice", - "admin_password": "admin" -} -``` - ---- - -## Group Management - -### Creating and Joining - -``` -/gcreate ops-team # Create a group (you become creator) -/g ops-team # Switch to group (auto-joins if not a member) -/gjoin ops-team # Explicitly join -``` - -Groups auto-create on first join if they do not exist. - -### Messaging - -When the peer is set to a group (shows as `#groupname` in the header), all messages go to that group. The server fans out to all members. +Once in a group, all messages you type go to that group. The server fans out to all members. ### Membership -- Creator can kick members with `/gkick ` -- Any member can leave with `/gleave` -- `/gmembers` shows all members with their aliases (if registered) +- `/gmembers` shows all members with aliases and online status. +- The creator can kick members with `/gkick `. +- Any member can leave with `/gleave`. -### Sender Keys (Implemented in Protocol) +### Sender Keys -The protocol implements Sender Keys for efficient group encryption: - -1. Each member generates a `SenderKey` (random 32-byte chain key) -2. The key is distributed to all members via 1:1 encrypted channels (`SenderKeyDistribution`) -3. Group messages are encrypted once with the sender's key (`GroupSenderKey`) -4. On member join/leave, all members rotate their sender keys - -This provides O(1) encryption per message instead of O(N) per-member encryption. +The protocol uses Sender Keys for efficient group encryption. Each member generates a random 32-byte chain key, distributes it to all other members over 1:1 encrypted channels, and encrypts group messages with their sender key. This gives O(1) encryption cost per message instead of O(N). Sender keys are rotated on member join or leave. --- -## Multi-Device Setup +## File Transfer -### Current Support +Files are transferred end-to-end encrypted through the relay server. -The server stores per-device bundles (`device::`). Multiple WebSocket connections per fingerprint are supported — all connected devices receive messages. +1. The sender reads the file and splits it into 64 KB chunks. +2. A `FileHeader` message is sent with the filename, total size, chunk count, and SHA-256 hash. +3. Each `FileChunk` is encrypted with the Double Ratchet session and sent sequentially. +4. The recipient reassembles all chunks and verifies the SHA-256 hash. +5. The completed file is saved to the current directory. -### Setting Up a Second Device - -1. On the new device, recover from mnemonic: `warzone recover <24 words>` -2. Register with the server: `warzone register --server http://...` -3. Both devices now share the same fingerprint and receive messages - -### Limitations - -- Ratchet sessions are per-device (not synchronized between devices) -- Starting a new session on one device does not invalidate the other's session -- Encrypted backup/restore can transfer session state between devices +Maximum file size: **10 MB**. Chunk size: **64 KB**. --- -## Encrypted Backup & Restore +## Friend List -### Creating a Backup +The friend list provides presence tracking for contacts you care about. -```bash -warzone backup my-backup.wzb -``` +- `/friend
` adds a friend (by fingerprint or alias). +- `/friend` lists all friends with their current online/offline status. +- `/unfriend
` removes a friend. -This exports: -- All ratchet sessions (Double Ratchet state) -- All pre-key secrets (signed + one-time) -- Encrypted with HKDF(seed, info="warzone-history") + ChaCha20-Poly1305 +The friend list is encrypted client-side and stored on the server as an opaque blob. The server relays it but cannot read its contents. -### Restoring a Backup +--- -```bash -warzone restore my-backup.wzb -``` +## Federation -Requires the same seed (passphrase prompt). Merges data without overwriting existing entries. +Federation connects two featherChat servers so that users on different servers can message each other transparently. -### Backup File Format +### Setup -``` -WZH1(4 bytes) + nonce(12) + ciphertext +Each server needs a federation config file: -Plaintext: JSON { - "version": 1, - "sessions": { "": "base64_bincode", ... }, - "pre_keys": { "spk:1": "base64_bytes", "otpk:1": "base64_bytes", ... } +```json +{ + "server_id": "alpha", + "shared_secret": "long-random-string-shared-between-both-servers", + "peer": { + "id": "bravo", + "url": "http://10.0.0.2:7700" + }, + "presence_interval_secs": 5 } ``` + +Start the server with federation enabled: + +```bash +warzone-server --bind 0.0.0.0:7700 --federation federation.json +``` + +### How It Works + +The two servers maintain a persistent WebSocket connection between them. When a client on server Alpha sends a message to a fingerprint registered on server Bravo, server Alpha forwards the message over the federation link. The recipient's server delivers it via their normal WebSocket connection. Presence information is exchanged on a configurable interval. + +From the user's perspective, federation is transparent. You address peers the same way regardless of which server they are on. + +--- + +## Multi-Device + +### Setup + +1. On the new device, recover from mnemonic: `warzone-client recover <24 words>` +2. Register with the server: `warzone-client register --server http://...` +3. Both devices share the same fingerprint and receive messages. + +### Device Management + +- `/devices` lists all active sessions for your identity. +- `/kick ` revokes a specific device session. + +Ratchet sessions are per-device and not synchronized between devices. Use encrypted backup/restore (`warzone-client backup` / `warzone-client restore`) to transfer session state. + +--- + +## Encrypted Backup and Restore + +```bash +# Export sessions and pre-keys, encrypted with your seed +warzone-client backup my-backup.wzb + +# Restore on another device (requires same seed) +warzone-client restore my-backup.wzb +``` + +The backup contains all Double Ratchet sessions and pre-key secrets. It is encrypted with HKDF(seed, info="warzone-history") + ChaCha20-Poly1305. From 210fbbb35b826c5dc32eb5236cf574ac6a9a31a0 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 07:34:45 +0400 Subject: [PATCH 05/50] feat: bot alias reservation + BOT_API.md documentation - Aliases ending with Bot/bot/_bot reserved for registered bots only - Non-bot users get clear error directing to /v1/bot/register - Bot registration auto-creates alias (@name_bot suffix) - BOT_API.md: full developer guide with endpoints, examples, echo bot - LLM_HELP.md: expanded bot section with update types + Python example Co-Authored-By: Claude Opus 4.6 (1M context) --- .../warzone-server/src/routes/aliases.rs | 12 + .../crates/warzone-server/src/routes/bot.rs | 13 + warzone/docs/BOT_API.md | 401 ++++++++++++++++++ warzone/docs/LLM_HELP.md | 41 +- 4 files changed, 458 insertions(+), 9 deletions(-) create mode 100644 warzone/docs/BOT_API.md diff --git a/warzone/crates/warzone-server/src/routes/aliases.rs b/warzone/crates/warzone-server/src/routes/aliases.rs index b6dbc7e..5fd84b7 100644 --- a/warzone/crates/warzone-server/src/routes/aliases.rs +++ b/warzone/crates/warzone-server/src/routes/aliases.rs @@ -123,6 +123,18 @@ async fn register_alias( return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" }))); } + // Reserve *Bot and *_bot suffixes for bots only + let is_bot_name = alias.ends_with("bot") || alias.ends_with("_bot"); + if is_bot_name { + // Check if this fingerprint is registered as a bot + let bot_key = format!("bot_fp:{}", fp); + let is_registered_bot = state.db.tokens.get(bot_key.as_bytes()) + .ok().flatten().is_some(); + if !is_registered_bot { + return Ok(Json(serde_json::json!({ "error": "aliases ending with 'Bot' or '_bot' are reserved for bots — register via /v1/bot/register first" }))); + } + } + // Check existing record for this alias if let Some(existing) = load_alias_record(&state.db.aliases, &alias) { if existing.fingerprint == fp { diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 3578986..1dd71ab 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -100,12 +100,25 @@ async fn register_bot( &token[..token.len().min(20)] ); + // Auto-register bot alias (name must end with Bot or _bot) + let bot_alias = if req.name.ends_with("Bot") || req.name.ends_with("_bot") || req.name.ends_with("bot") { + req.name.to_lowercase() + } else { + format!("{}_bot", req.name.to_lowercase()) + }; + let alias_key = format!("a:{}", bot_alias); + let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes()); + let fp_key = format!("fp:{}", fp); + let _ = state.db.aliases.insert(fp_key.as_bytes(), bot_alias.as_bytes()); + tracing::info!("Bot alias @{} registered for {}", bot_alias, fp); + Ok(Json(serde_json::json!({ "ok": true, "result": { "token": token, "name": req.name, "fingerprint": fp, + "alias": format!("@{}", bot_alias), } }))) } diff --git a/warzone/docs/BOT_API.md b/warzone/docs/BOT_API.md new file mode 100644 index 0000000..ea5976e --- /dev/null +++ b/warzone/docs/BOT_API.md @@ -0,0 +1,401 @@ +# featherChat Bot API + +## Overview + +featherChat exposes a **Telegram Bot API-compatible** HTTP interface, allowing +developers to build bots that interact with featherChat users using familiar +patterns. Bots register with the server, receive a token, and communicate via +long-polling (webhook support is planned). + +Key properties of v1: + +- Bot aliases **must** end with `Bot`, `bot`, or `_bot` (auto-enforced on + registration). +- Bots receive encrypted user messages as **base64 blobs** (`raw_encrypted` + field). Plaintext bot-to-bot messages are delivered with a readable `text` + field. +- Bot-sent messages are **plaintext** (not E2E encrypted) in v1. +- `chat_id` values are hex fingerprints (not numeric Telegram-style IDs). + +--- + +## Quick Start + +``` +1. Register your bot: + POST /v1/bot/register + {"name": "WeatherBot", "fingerprint": "aabbccdd..."} + +2. Extract the token from the response. + +3. Poll for updates: + POST /v1/bot//getUpdates + {"timeout": 5} + +4. Send a reply: + POST /v1/bot//sendMessage + {"chat_id": "", "text": "Hello!"} +``` + +--- + +## Endpoints + +### 1. Register a Bot + +``` +POST /v1/bot/register +``` + +Creates a new bot, stores it in the server database, and auto-registers an +alias. + +**Request:** + +```json +{ + "name": "MyBot", + "fingerprint": "aabbccdd1122334455667788aabbccdd" +} +``` + +| Field | Type | Description | +|---------------|--------|----------------------------------------------| +| `name` | string | Display name. Alias suffix auto-added if needed. | +| `fingerprint` | string | Hex-encoded public key fingerprint for the bot. | + +**Response:** + +```json +{ + "ok": true, + "result": { + "token": "aabbccdd11223344:9f8e7d6c5b4a39281706abcdef012345", + "name": "MyBot", + "fingerprint": "aabbccdd1122334455667788aabbccdd", + "alias": "@mybot_bot" + } +} +``` + +**Token format:** `:<32-hex-random-bytes>` + +**Alias rules:** + +- If the name already ends with `Bot`, `bot`, or `_bot`, the alias is the + lowercased name (e.g. `WeatherBot` -> `@weatherbot`). +- Otherwise `_bot` is appended (e.g. `weather` -> `@weather_bot`). +- The alias is registered in both directions (alias -> fingerprint and + fingerprint -> alias). + +--- + +### 2. Get Bot Info + +``` +GET /v1/bot/:token/getMe +``` + +Returns information about the bot in a Telegram-compatible shape. + +**Response (valid token):** + +```json +{ + "ok": true, + "result": { + "id": "aabbccdd1122334455667788aabbccdd", + "is_bot": true, + "first_name": "MyBot", + "username": "MyBot" + } +} +``` + +**Response (invalid token):** + +```json +{ + "ok": false, + "description": "invalid token" +} +``` + +--- + +### 3. Get Updates (Long-Poll) + +``` +POST /v1/bot/:token/getUpdates +``` + +Returns queued messages for the bot and deletes them from the queue. + +**Request:** + +```json +{ + "timeout": 5 +} +``` + +| Field | Type | Description | +|-----------|------|-----------------------------------------------------| +| `timeout` | u64 | Optional. Long-poll wait in seconds. **Capped at 5.** | + +If the queue is empty and `timeout > 0`, the server waits up to `timeout` +seconds (max 5) before returning an empty result, giving new messages a chance +to arrive. + +**Response:** + +```json +{ + "ok": true, + "result": [ ...updates... ] +} +``` + +#### Update Types + +**Encrypted message** (from a user — bot must decrypt if it has a session): + +```json +{ + "update_id": 1, + "message": { + "message_id": "uuid", + "from": { + "id": "sender_fingerprint", + "is_bot": false, + "first_name": "sender_finge" + }, + "chat": { + "id": "sender_fingerprint", + "type": "private" + }, + "date": 1711670400, + "text": null, + "raw_encrypted": "base64-encoded-wiremessage..." + } +} +``` + +**Key exchange** (X3DH session initiation — same shape as encrypted message): + +```json +{ + "update_id": 2, + "message": { + "message_id": "uuid", + "from": { "id": "sender_fp", "is_bot": false, "first_name": "sender_fp..." }, + "chat": { "id": "sender_fp", "type": "private" }, + "date": 1711670400, + "text": null, + "raw_encrypted": "base64-encoded-keyexchange..." + } +} +``` + +**Call signal:** + +```json +{ + "update_id": 3, + "message": { + "message_id": "uuid", + "from": { "id": "sender_fp", "is_bot": false, "first_name": "sender_fp..." }, + "chat": { "id": "sender_fp", "type": "private" }, + "date": 1711670400, + "text": "/call_Offer", + "call_signal": { + "type": "Offer", + "payload": "SDP or ICE data..." + } + } +} +``` + +**File header:** + +```json +{ + "update_id": 4, + "message": { + "message_id": "uuid", + "from": { "id": "sender_fp", "is_bot": false, "first_name": "sender_fp..." }, + "chat": { "id": "sender_fp", "type": "private" }, + "date": 1711670400, + "document": { + "file_name": "report.pdf", + "file_size": 204800 + } + } +} +``` + +**Bot message (plaintext, from another bot via `sendMessage`):** + +```json +{ + "update_id": 5, + "message": { + "message_id": "uuid", + "from": { + "id": "other_bot_fingerprint", + "is_bot": true + }, + "chat": { + "id": "other_bot_fingerprint", + "type": "private" + }, + "date": 1711670400, + "text": "Hello from the other bot!" + } +} +``` + +> **Note:** Receipt and internal wire messages (FileChunk, GroupSenderKey, +> SenderKeyDistribution) are silently skipped and never delivered as updates. + +--- + +### 4. Send Message + +``` +POST /v1/bot/:token/sendMessage +``` + +Sends a **plaintext** message to a user or another bot. + +**Request:** + +```json +{ + "chat_id": "aabbccdd1122334455667788aabbccdd", + "text": "Hello from MyBot!" +} +``` + +| Field | Type | Description | +|-----------|--------|--------------------------------------------------| +| `chat_id` | string | Recipient fingerprint (hex) or Ethereum address. | +| `text` | string | Plaintext message body. | + +Non-hex characters in `chat_id` are stripped and the value is lowercased before +routing. + +**Response:** + +```json +{ + "ok": true, + "result": { + "message_id": "550e8400-e29b-41d4-a716-446655440000", + "chat": { + "id": "aabbccdd1122334455667788aabbccdd", + "type": "private" + }, + "text": "Hello from MyBot!", + "date": 1711670400, + "delivered": true + } +} +``` + +The `delivered` field indicates whether the message was sent over a live +WebSocket connection (`true`) or queued for later retrieval (`false`). + +--- + +## Alias Rules + +| Rule | Detail | +|------|--------| +| Bot aliases **must** end with `Bot`, `bot`, or `_bot` | Enforced at registration time. | +| Non-bot users **cannot** register aliases with these suffixes | Reserved for bots. | +| Auto-registered on bot creation | No separate alias step needed. | +| Users message bots via alias | e.g. `@mybot_bot`, resolved like any other alias. | + +--- + +## Differences from Telegram Bot API + +| Feature | Telegram | featherChat | +|---------|----------|-------------| +| `chat_id` type | Numeric integer | Hex fingerprint string | +| `getUpdates` timeout | Up to 50s | Capped at **5s** | +| Message content | Always plaintext | Encrypted messages arrive as `raw_encrypted` base64; bot must decrypt if it has a session | +| Bot-sent messages | Plaintext | Plaintext (not E2E encrypted) in v1 | +| Inline keyboards / callback queries | Supported | Not yet (planned) | +| Media groups | Supported | Not yet (planned) | +| Webhooks (`setWebhook`) | Supported | Not yet (planned) | +| File download (`getFile`) | Supported | Not yet (planned) | + +--- + +## Example: Simple Echo Bot (Python) + +```python +import requests +import time + +TOKEN = "your_bot_token" +API = f"http://localhost:7700/v1/bot/{TOKEN}" + +while True: + resp = requests.post(f"{API}/getUpdates", json={"timeout": 5}).json() + for update in resp.get("result", []): + msg = update.get("message", {}) + text = msg.get("text") or "[encrypted]" + chat_id = msg.get("chat", {}).get("id", "") + if text and chat_id: + requests.post(f"{API}/sendMessage", json={ + "chat_id": chat_id, + "text": f"Echo: {text}", + }) + time.sleep(1) +``` + +### Example: Registration (curl) + +```bash +curl -X POST http://localhost:7700/v1/bot/register \ + -H "Content-Type: application/json" \ + -d '{"name": "EchoBot", "fingerprint": "aabbccdd1122334455667788aabbccdd"}' +``` + +--- + +## Authentication + +All bot endpoints (except `/register`) are authenticated by the **token** in +the URL path. Tokens are generated at registration time and stored server-side. +There is no expiration mechanism in v1 -- tokens remain valid until the server +database is cleared. + +The token grants full access to poll and send messages as the bot. **Treat it +like a password.** + +--- + +## Internal Details + +- Bot info is stored in the `tokens` sled tree under key `bot:`. +- A reverse lookup `bot_fp:` -> `` is also maintained. +- Aliases are stored in the `aliases` sled tree (`a:` -> fingerprint, + `fp:` -> alias). +- Queued messages live in the `messages` sled tree under prefix + `queue::*` and are deleted after `getUpdates` consumes them. +- Messages are delivered via `deliver_or_queue` -- live WebSocket if online, + otherwise queued. + +--- + +## Future Plans + +- **Webhook mode** (`setWebhook`) -- push updates to a URL instead of polling. +- **Inline keyboards and callback queries** -- interactive message buttons. +- **E2E encrypted bot sessions** -- bots participate in X3DH key exchange. +- **File send/receive APIs** -- `sendDocument`, `getFile`. +- **Group bot support** -- bots in group chats with sender-key encryption. diff --git a/warzone/docs/LLM_HELP.md b/warzone/docs/LLM_HELP.md index 0bcd2f8..cbc415d 100644 --- a/warzone/docs/LLM_HELP.md +++ b/warzone/docs/LLM_HELP.md @@ -45,6 +45,8 @@ ETH address | 0x742d35Cc... | derived from same seed, checksum format All 3 formats work in /peer. Aliases resolve to fp via server. One alias per user. Register with /alias, recover with recovery key. +Bot alias reservation: names ending in Bot, bot, or _bot are reserved for the Bot API. Non-bot users cannot register these aliases. + ## Quick Start 1. `warzone init` -- generates seed, saves identity.seed, prints 24-word mnemonic. WRITE IT DOWN. @@ -127,23 +129,44 @@ Problem | Cause | Fix "file too large" | over 10MB | split file manually no prekeys available | recipient's one-time prekeys exhausted | they need to re-register or come online -## Bot API +## Bot API (Telegram-compatible) -Telegram-compatible REST API. Base: /v1/ +Register: POST /v1/bot/register {"name":"MyBot","fingerprint":""} +→ returns token + auto-creates @mybot_bot alias -Endpoint | Method | Body | Returns ---- | --- | --- | --- -/bot/register | POST | {"name":"mybot","fingerprint":"abc..."} | {"token":"...","name":"..."} -/bot/:token/getMe | GET | -- | bot info -/bot/:token/getUpdates | POST | {"timeout":5} | array of Update objects -/bot/:token/sendMessage | POST | {"chat_id":"","text":"hello"} | msg confirmation +Bot aliases must end with Bot, bot, or _bot. Non-bots cannot use these. + +|Endpoint|Method|Body| +|---|---|---| +|/bot/:token/getMe|GET|—| +|/bot/:token/getUpdates|POST|{"timeout":5}| +|/bot/:token/sendMessage|POST|{"chat_id":"","text":"Hello"}| - Token format: fp_prefix:random_hex - getUpdates: long-poll (max 5s), returns then deletes queued msgs - sendMessage: plaintext JSON, NOT E2E encrypted -- Updates include: messages, key exchanges, call signals, file headers - Bot msgs delivered via same routing (WS push or DB queue) +Update types in getUpdates: +- Encrypted msg: text=null, raw_encrypted=base64 +- Bot msg (plaintext): text="actual text", from.is_bot=true +- Call signal: text="/call_Offer", call_signal={type,payload} +- File: document={file_name,file_size} + +v1 limits: sendMessage is plaintext (no E2E), timeout max 5s, no webhooks yet. + +Echo bot (Python): +```python +import requests, time +TOKEN = "your_token" +API = f"http://srv:7700/v1/bot/{TOKEN}" +while True: + for u in requests.post(f"{API}/getUpdates",json={"timeout":5}).json().get("result",[]): + m = u["message"] + if m.get("text"): requests.post(f"{API}/sendMessage",json={"chat_id":m["chat"]["id"],"text":"Echo: "+m["text"]}) + time.sleep(1) +``` + ## Server API (other endpoints) - POST /v1/register -- upload prekey bundle From 953b3bd13a7d0fa8b17e36ca9e9bf407c27a6bff Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 07:39:30 +0400 Subject: [PATCH 06/50] docs: CLAUDE.md design principles, update ARCHITECTURE + SECURITY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: design principles (E2E by default, semi-trusted server, federation transparency, TG bot compat), coding conventions for Rust/TUI/ WASM/federation/bots, task naming, key files reference - ARCHITECTURE.md: added bots to high-level diagram, friends/bot/resolve modules, 9 sled trees (was 7), bot API sequence diagram, addressing table, federated features table, test count 72→122 - SECURITY.md: v0.0.21, added friend list/API auth/device/bot alias to protected assets, auth & authorization section, rate limiting, session recovery Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/CLAUDE.md | 78 ++++++++++++++++++++++++++++++++++++ warzone/docs/ARCHITECTURE.md | 78 +++++++++++++++++++++++++++++++++--- warzone/docs/SECURITY.md | 37 ++++++++++++++++- 3 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 warzone/CLAUDE.md diff --git a/warzone/CLAUDE.md b/warzone/CLAUDE.md new file mode 100644 index 0000000..5b982e8 --- /dev/null +++ b/warzone/CLAUDE.md @@ -0,0 +1,78 @@ +# featherChat — Design Principles & Conventions + +## Architecture Principles + +1. **Single seed, multiple identities** — Ed25519 (messaging), X25519 (encryption), secp256k1 (ETH address) all derived from one BIP39 seed via HKDF with domain-separated info strings. + +2. **E2E by default** — All user messages are Double Ratchet encrypted. The server NEVER sees plaintext. Friend lists are client-side encrypted. Only bot messages are plaintext (v1). + +3. **Server is semi-trusted** — Server sees metadata (who talks to whom, timing, groups) but cannot read message content. Design all features with this trust boundary in mind. + +4. **Federation is transparent** — Users don't need to know which server their peer is on. Key lookup, alias resolution, and message delivery automatically proxy through federation. + +5. **Telegram Bot API compatibility** — Bot API follows Telegram conventions (getUpdates, sendMessage, token-in-URL). Bot aliases must end with Bot/bot/_bot. + +6. **Auth on writes, open reads** — All POST/write endpoints require bearer tokens. GET/read endpoints are public (needed for key exchange before auth is possible). + +## Coding Conventions + +### Rust +- Workspace crates: protocol (no I/O), server (axum), client (ratatui), wasm (wasm-bindgen), mule (future) +- Error handling: `AppResult` in server, `anyhow::Result` in client, `ProtocolError` in protocol +- State: `AppState` with `Arc>` for shared state, `Arc` for sled +- Auth: `AuthFingerprint` extractor as first handler param for protected routes +- Fingerprints: always normalize with `normfp()` (strip non-hex, lowercase) +- New routes: create `routes/.rs`, add `pub fn routes() -> Router`, merge in `routes/mod.rs` + +### TUI +- 7 modules in `tui/`: types, draw, commands, input, file_transfer, network, mod +- All ChatLine must include `timestamp: Local::now()` +- Add new commands to both the handler chain AND `/help` text +- Self-messaging prevention: check `normfp(&peer) != normfp(&self.our_fp)` + +### Web (WASM) +- JS embedded in `routes/web.rs` as Rust raw string — careful with escaping +- Service worker cache version must be bumped on WASM changes (`wz-vN`) +- `WasmSession::initiate()` stores X3DH result — `encrypt_key_exchange` must NOT re-initiate + +### Federation +- Persistent WS between servers, NOT HTTP polling +- Presence re-pushed every 10s + on connect +- Key lookup: proxy to peer for non-local fingerprints (never cache remote bundles) +- Alias resolution: fall back to peer if not found locally +- Registration: check peer to enforce global uniqueness + +### Bot API +- Token stored as `bot:` in tokens tree +- Reverse lookup: `bot_fp:` → token +- Alias auto-registered on bot creation with `_bot` suffix +- Reserved aliases: `*Bot`, `*bot`, `*_bot` blocked for non-bots + +## Task Naming + +`FC-P{phase}-T{task}[-S{subtask}]` + +See `docs/TASK_PLAN.md` for the full breakdown. + +## Testing + +- Protocol: unit tests in each module's `#[cfg(test)]` +- TUI: unit tests for types, input, draw (using ratatui TestBackend) +- WASM: can't test natively (js-sys dependency) — test equivalent logic in protocol crate +- Server: no integration tests yet (planned) + +## Key Files + +| What | Where | +|------|-------| +| Wire format | `warzone-protocol/src/message.rs` | +| Crypto primitives | `warzone-protocol/src/crypto.rs` | +| Server state | `warzone-server/src/state.rs` | +| All routes | `warzone-server/src/routes/mod.rs` | +| Federation | `warzone-server/src/federation.rs` | +| TUI commands | `warzone-client/src/tui/commands.rs` | +| Web client | `warzone-server/src/routes/web.rs` | +| WASM bridge | `warzone-wasm/src/lib.rs` | +| Task plan | `docs/TASK_PLAN.md` | +| Bot API docs | `docs/BOT_API.md` | +| LLM help ref | `docs/LLM_HELP.md` | diff --git a/warzone/docs/ARCHITECTURE.md b/warzone/docs/ARCHITECTURE.md index 8a67596..a95a4f1 100644 --- a/warzone/docs/ARCHITECTURE.md +++ b/warzone/docs/ARCHITECTURE.md @@ -12,11 +12,11 @@ graph TB CLI[CLI Client] --> PROTO[warzone-protocol] TUI[TUI Client] --> PROTO WEB[Web Client WASM] --> PROTO + BOT[Bots TG API] -->|HTTP| SRVA PROTO -->|HTTP / WS| SRVA[Server Alpha] PROTO -->|HTTP / WS| SRVB[Server Bravo] SRVA <-->|Federation WS| SRVB SRVA -->|Call Signaling| WZP[WarzonePhone Relay] - SRVB -->|Call Signaling| WZP ``` --- @@ -78,6 +78,7 @@ warzone/ | `sender_keys` | Sender Key protocol for group encryption | | `history` | Encrypted backup/restore | | `ethereum` | secp256k1, Keccak-256, Ethereum address derivation | +| `friends` | E2E encrypted friend list (encrypt/decrypt with HKDF key) | | `types` | Fingerprint, DeviceId, SessionId, MessageId | ### warzone-server @@ -86,7 +87,7 @@ warzone/ |----------------------|---------------------------------------------------| | `main` | CLI args, startup, federation init | | `state` | AppState, Connections, CallState, DedupTracker | -| `db` | 7 sled trees: keys, messages, groups, aliases, tokens, calls, missed_calls | +| `db` | 9 sled trees: keys, messages, groups, aliases, tokens, calls, missed_calls, friends, eth_addresses | | `federation` | Peer config, presence sync, message forwarding | | `auth_middleware` | Bearer token extractor (401 on protected routes) | | `routes/auth` | Challenge-response authentication | @@ -100,6 +101,9 @@ warzone/ | `routes/wzp` | WZP relay config + service token | | `routes/aliases` | Alias CRUD with TTL + recovery keys | | `routes/keys` | Pre-key bundle registration & retrieval | +| `routes/friends` | Encrypted friend list blob storage (GET/POST) | +| `routes/bot` | Telegram Bot API compatibility layer | +| `routes/resolve` | Address resolution (ETH/alias/fingerprint → fp) | ### warzone-client (TUI) @@ -238,6 +242,13 @@ Public (no auth): GET /v1/wzp/relay-config WZP relay address + token GET /v1/federation/status Federation health GET /v1/ws/:fp WebSocket upgrade + GET /v1/friends Encrypted friend list (auth) + POST /v1/friends Save friend list (auth) + GET /v1/resolve/:address ETH/alias/fp resolution + POST /v1/bot/register Register a bot + GET /v1/bot/:token/getMe Bot identity + POST /v1/bot/:token/getUpdates Long-poll for messages + POST /v1/bot/:token/sendMessage Send message as bot POST /v1/auth/challenge|verify|validate Federation (HMAC-authenticated, server-to-server): @@ -366,6 +377,16 @@ sequenceDiagram | Peer restarts | Presence repopulates on WS reconnect | | HMAC mismatch | Request rejected with 401 | +### Federated Features + +| Feature | How it works | +|---------|-------------| +| Message forwarding | deliver_or_queue() checks remote presence, forwards via WS | +| Key lookup | get_bundle() proxies to peer if fingerprint is not local | +| Alias resolution | resolve_alias() falls back to peer server | +| ETH resolution | resolve endpoint checks peer via HTTP | +| Presence | Bidirectional sync every 10s + on-connect | + --- ## Call Infrastructure (WZP Integration) @@ -438,6 +459,51 @@ flowchart LR --- +## Bot API (Telegram-Compatible) + +```mermaid +sequenceDiagram + participant Dev as Bot Developer + participant S as featherChat Server + participant U as User + + Dev->>S: POST /v1/bot/register {name, fp} + S->>Dev: {token, alias: "@mybot_bot"} + + loop Long-poll + Dev->>S: POST /bot/:token/getUpdates + S->>Dev: [updates...] + end + + U->>S: Message to @mybot_bot + S->>S: Queue for bot fp + Dev->>S: getUpdates → receives message + Dev->>S: POST /bot/:token/sendMessage + S->>U: Deliver reply via WS +``` + +- Bots register with a fingerprint and get a token +- Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced) +- Non-bot users cannot register reserved aliases +- `getUpdates` returns Telegram-compatible Update objects +- `sendMessage` delivers plaintext (no E2E in v1) +- Messages from users arrive as encrypted blobs (base64) or plaintext bot messages + +### Addressing + +Three address formats, all interchangeable: + +| Format | Example | Usage | +|--------|---------|-------| +| Fingerprint | `522d:4d6e:a8ee:588a:...` | Internal routing, crypto | +| ETH address | `0x742d35Cc6634C0532...` | User-facing display | +| Alias | `@alice`, `@weatherbot` | Human-friendly | + +Resolution: `GET /v1/resolve/:address` accepts any format, returns fingerprint. +ETH↔fingerprint mapping stored on key registration. + +--- + ## Security Model ### What's Protected @@ -491,7 +557,7 @@ graph TB ## Storage Model -### Server sled Trees (7) +### Server sled Trees (9) | Tree | Key Format | Value | |----------------|---------------------------|--------------------------| @@ -502,6 +568,8 @@ graph TB | `tokens` | `` | JSON {fp, expires_at} | | `calls` | `` | JSON CallState | | `missed_calls` | `missed::` | JSON {caller, timestamp} | +| `friends` | `` | Encrypted blob (ChaCha20) | +| `eth_addresses` | `0x...` or `rev:` | ETH↔fingerprint mapping | ### Client sled Trees (5) @@ -519,11 +587,11 @@ graph TB | Crate | Tests | Coverage | |-------|------:|---------| -| warzone-protocol | 28 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic | +| warzone-protocol | 34 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client | | 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 | +| **Total** | **122** | All passing | WZP side: 15 cross-project identity tests + 17 integration tests (separate repo). diff --git a/warzone/docs/SECURITY.md b/warzone/docs/SECURITY.md index 1b44c9b..b357421 100644 --- a/warzone/docs/SECURITY.md +++ b/warzone/docs/SECURITY.md @@ -1,7 +1,7 @@ # Warzone Messenger (featherChat) — Security Model & Threat Analysis -**Version:** 0.0.20 -**Last Updated:** 2026-03-28 +**Version:** 0.0.21 +**Last Updated:** 2026-03-29 --- @@ -20,6 +20,10 @@ | Session state | Encrypted backup (HKDF + ChaCha20-Poly1305) | | Pre-key authenticity | Ed25519 signature on signed pre-keys | | Key exchange integrity | X3DH with 3-4 DH operations | +| Friend list | E2E encrypted blob (ChaCha20 + HKDF-derived key) | +| API write operations | Bearer token middleware on all POST routes | +| Device sessions | Kick/revoke-all, max 5 WS per fingerprint | +| Bot aliases | Reserved suffixes (Bot/bot/_bot) enforced | ### What Is NOT Protected (Current) @@ -32,6 +36,7 @@ | Message sizes | Server sees encrypted message sizes | | Online/offline status | Server knows when clients connect via WebSocket| | IP addresses | Server sees client IP addresses | +| Bot messages | Plaintext (not E2E) in v1 — bots don't hold ratchet sessions | ### Trust Boundaries @@ -63,6 +68,34 @@ └─────────────────────────────────────────────────────┘ ``` +### Authentication & Authorization + +- Challenge-response: Ed25519 signature over random challenge +- Bearer tokens: 7-day TTL, required on all write endpoints +- Auth middleware: `AuthFingerprint` extractor returns 401 on invalid/missing token +- Bot tokens: separate namespace (`bot:`), validated per-request +- Federation: shared secret compared on WS auth frame + +Protected endpoints (require bearer token): +- messages/send, groups/*, aliases/*, calls/*, devices/*, friends, presence/batch + +Public endpoints (no auth): +- keys/:fp, messages/poll, groups GET, alias/resolve, resolve/:address, bot/* + +### Rate Limiting & Abuse Prevention + +- Global: 200 concurrent requests (tower ConcurrencyLimitLayer) +- Per-fingerprint: max 5 WebSocket connections +- Stale connections auto-cleaned on new registrations +- Federation: auto-reconnect with 3s backoff (no amplification) + +### Session Recovery + +On ratchet decryption failure: +1. Corrupted session deleted from local DB +2. Warning shown: "[session reset]" +3. Next KeyExchange re-establishes the session automatically + --- ## Cryptographic Primitives From fcbf2d58590bdabbb9a60bdf9bde39ab7d1ea3b5 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 07:50:14 +0400 Subject: [PATCH 07/50] feat: complete Telegram-compatible Bot API + bot dev guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot API (routes/bot.rs — full rewrite): - getUpdates: persistent update_id counter, offset acknowledgement, limit (max 100), long-poll up to 30s with 1s intervals - sendMessage: parse_mode, reply_to_message_id, reply_markup (inline keyboards) - answerCallbackQuery: acknowledge button clicks - editMessageText: update sent messages - setWebhook / deleteWebhook / getWebhookInfo: webhook configuration - sendDocument: file reference with caption - Bot queue: raw messages migrated to bot_queue:: for ordering Web client (routes/web.rs): - Bot messages rendered properly (was showing "[message could not be decrypted]") - Handles bot_message, bot_edit, bot_document as both Text and Binary WS frames - Inline keyboard buttons rendered as bracketed text - Missed call notifications handled in Text frame path Docs: - LLM_BOT_DEV.md: token-optimized bot dev reference for coding assistant LLM (Python + Node.js examples, all endpoints, TG compatibility table) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../crates/warzone-server/src/routes/bot.rs | 716 ++++++++++++++---- .../crates/warzone-server/src/routes/web.rs | 68 +- warzone/docs/LLM_BOT_DEV.md | 214 ++++++ 3 files changed, 829 insertions(+), 169 deletions(-) create mode 100644 warzone/docs/LLM_BOT_DEV.md diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 1dd71ab..969d54f 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -3,6 +3,18 @@ //! Bots register with a fingerprint and get a token. //! They use `/bot/getUpdates` and `/bot/sendMessage` //! to communicate with featherChat users. +//! +//! Supported endpoints (Telegram-compatible): +//! - `POST /bot/register` (featherChat-specific) +//! - `GET /bot/:token/getMe` +//! - `POST /bot/:token/getUpdates` +//! - `POST /bot/:token/sendMessage` +//! - `POST /bot/:token/answerCallbackQuery` +//! - `POST /bot/:token/editMessageText` +//! - `POST /bot/:token/setWebhook` +//! - `POST /bot/:token/deleteWebhook` +//! - `GET /bot/:token/getWebhookInfo` +//! - `POST /bot/:token/sendDocument` use axum::{ extract::{Path, State}, @@ -20,9 +32,15 @@ use crate::state::AppState; pub fn routes() -> Router { Router::new() .route("/bot/register", post(register_bot)) + .route("/bot/:token/getMe", get(get_me)) .route("/bot/:token/getUpdates", post(get_updates)) .route("/bot/:token/sendMessage", post(send_message)) - .route("/bot/:token/getMe", get(get_me)) + .route("/bot/:token/answerCallbackQuery", post(answer_callback_query)) + .route("/bot/:token/editMessageText", post(edit_message_text)) + .route("/bot/:token/setWebhook", post(set_webhook)) + .route("/bot/:token/deleteWebhook", post(delete_webhook)) + .route("/bot/:token/getWebhookInfo", get(get_webhook_info)) + .route("/bot/:token/sendDocument", post(send_document)) } // --------------------------------------------------------------------------- @@ -37,6 +55,43 @@ fn validate_bot_token(state: &AppState, token: &str) -> Option`. +fn next_update_id(state: &AppState, bot_fp: &str) -> u64 { + let key = format!("bot_update_id:{}", bot_fp); + let current = state + .db + .tokens + .get(key.as_bytes()) + .ok() + .flatten() + .and_then(|v| { + let bytes: [u8; 8] = v.as_ref().try_into().ok()?; + Some(u64::from_be_bytes(bytes)) + }) + .unwrap_or(1); + let next = current + 1; + let _ = state + .db + .tokens + .insert(key.as_bytes(), &next.to_be_bytes()); + current +} + +/// Store an update in the bot's persistent queue with an assigned update_id. +/// +/// Key format: `bot_queue::` to ensure lexicographic ordering. +fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value) { + let uid = next_update_id(state, bot_fp); + let queue_key = format!("bot_queue:{}:{:020}", bot_fp, uid); + let mut enriched = update; + enriched["update_id"] = serde_json::json!(uid); + if let Ok(bytes) = serde_json::to_vec(&enriched) { + let _ = state.db.messages.insert(queue_key.as_bytes(), bytes); + } +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -145,15 +200,32 @@ async fn get_me( } } +// --------------------------------------------------------------------------- +// getUpdates — with offset/limit/timeout support +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct GetUpdatesParams { + #[serde(default)] + offset: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + timeout: Option, +} + /// `POST /bot/:token/getUpdates` -- long-poll for messages sent to this bot. /// -/// Reads from the `queue::*` key range in the messages sled tree, -/// converts each entry into a Telegram-style `Update` object, and deletes -/// consumed entries. +/// Migrates raw queue entries (from `queue::*`) into the persistent +/// bot update queue (`bot_queue::`) on each call, then +/// returns updates filtered by `offset` and capped by `limit`. +/// +/// When `offset` is provided, all updates with `update_id < offset` are +/// acknowledged (deleted), matching Telegram Bot API semantics. async fn get_updates( State(state): State, Path(token): Path, - Json(params): Json, + Json(params): Json, ) -> Json { let bot_info = match validate_bot_token(&state, &token) { Some(info) => info, @@ -165,178 +237,60 @@ async fn get_updates( } }; let bot_fp = bot_info["fingerprint"].as_str().unwrap_or(""); - let timeout = params - .get("timeout") - .and_then(|v| v.as_u64()) - .unwrap_or(0); + let limit = params.limit.unwrap_or(100).min(100); + let timeout = params.timeout.unwrap_or(0); - let prefix = format!("queue:{}", bot_fp); - let mut updates = Vec::new(); - let mut keys_to_delete = Vec::new(); - let mut update_id = 1u64; + // Step 1: Migrate raw queue entries into the persistent bot_queue. + migrate_raw_queue(&state, bot_fp); - for item in state.db.messages.scan_prefix(prefix.as_bytes()) { - let (key, value) = match item { - Ok(pair) => pair, - Err(_) => continue, - }; - - if let Ok(wire) = - bincode::deserialize::(&value) - { - match wire { - warzone_protocol::message::WireMessage::Message { - id, - sender_fingerprint, - .. - } => { - let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value); - updates.push(serde_json::json!({ - "update_id": update_id, - "message": { - "message_id": id, - "from": { - "id": &sender_fingerprint, - "is_bot": false, - "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], - }, - "chat": { - "id": &sender_fingerprint, - "type": "private", - }, - "date": chrono::Utc::now().timestamp(), - "text": null, - "raw_encrypted": raw_b64, - } - })); - update_id += 1; + // Step 2: If offset is provided, delete all acknowledged updates (update_id < offset). + if let Some(offset) = params.offset { + let prefix = format!("bot_queue:{}:", bot_fp); + let mut to_delete = Vec::new(); + for item in state.db.messages.scan_prefix(prefix.as_bytes()) { + let (key, value) = match item { + Ok(pair) => pair, + Err(_) => continue, + }; + if let Ok(update) = serde_json::from_slice::(&value) { + let uid = update["update_id"].as_i64().unwrap_or(0); + if uid < offset { + to_delete.push(key); + } else { + // Keys are ordered, so once we pass offset we can stop scanning + // for deletions. + break; } - warzone_protocol::message::WireMessage::KeyExchange { - id, - sender_fingerprint, - .. - } => { - let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value); - updates.push(serde_json::json!({ - "update_id": update_id, - "message": { - "message_id": id, - "from": { - "id": &sender_fingerprint, - "is_bot": false, - "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], - }, - "chat": { - "id": &sender_fingerprint, - "type": "private", - }, - "date": chrono::Utc::now().timestamp(), - "text": null, - "raw_encrypted": raw_b64, - } - })); - update_id += 1; - } - warzone_protocol::message::WireMessage::CallSignal { - id, - sender_fingerprint, - signal_type, - payload, - .. - } => { - updates.push(serde_json::json!({ - "update_id": update_id, - "message": { - "message_id": id, - "from": { - "id": &sender_fingerprint, - "is_bot": false, - "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], - }, - "chat": { - "id": &sender_fingerprint, - "type": "private", - }, - "date": chrono::Utc::now().timestamp(), - "text": format!("/call_{:?}", signal_type), - "call_signal": { - "type": format!("{:?}", signal_type), - "payload": payload, - }, - } - })); - update_id += 1; - } - warzone_protocol::message::WireMessage::FileHeader { - id, - sender_fingerprint, - filename, - file_size, - .. - } => { - updates.push(serde_json::json!({ - "update_id": update_id, - "message": { - "message_id": id, - "from": { - "id": &sender_fingerprint, - "is_bot": false, - "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], - }, - "chat": { - "id": &sender_fingerprint, - "type": "private", - }, - "date": chrono::Utc::now().timestamp(), - "document": { - "file_name": filename, - "file_size": file_size, - }, - } - })); - update_id += 1; - } - // Skip receipts — don't deliver as updates. - warzone_protocol::message::WireMessage::Receipt { .. } => {} - // Skip other variants (FileChunk, GroupSenderKey, SenderKeyDistribution). - _ => {} - } - } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { - // Try plaintext bot message (from other bots via sendMessage). - if bot_msg.get("type").and_then(|v| v.as_str()) == Some("bot_message") { - updates.push(serde_json::json!({ - "update_id": update_id, - "message": { - "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), - "from": { - "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), - "is_bot": true, - }, - "chat": { - "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), - "type": "private", - }, - "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), - "text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), - } - })); - update_id += 1; } } - - keys_to_delete.push(key); + for key in &to_delete { + let _ = state.db.messages.remove(key); + } } - // Remove consumed messages. - for key in &keys_to_delete { - let _ = state.db.messages.remove(key); - } + // Step 3: Collect remaining updates up to `limit`. + let updates = collect_updates(&state, bot_fp, limit); - // Simplified long-poll: if the queue was empty, wait up to `timeout` seconds - // (capped at 5 s) before returning, giving new messages a chance to arrive. + // Step 4: Long-poll if empty. if updates.is_empty() && timeout > 0 { - let wait = std::cmp::min(timeout, 5); - tokio::time::sleep(std::time::Duration::from_secs(wait)).await; + let wait = std::cmp::min(timeout, 30); + // Poll in 1-second intervals so new messages are picked up promptly. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait); + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + // Check for newly arrived raw messages. + migrate_raw_queue(&state, bot_fp); + let polled = collect_updates(&state, bot_fp, limit); + if !polled.is_empty() { + return Json(serde_json::json!({ + "ok": true, + "result": polled, + })); + } + if tokio::time::Instant::now() >= deadline { + break; + } + } } Json(serde_json::json!({ @@ -345,10 +299,219 @@ async fn get_updates( })) } +/// Migrate raw `queue::*` entries into `bot_queue::`. +/// +/// Each raw entry is converted into a Telegram-style Update JSON object, assigned +/// a persistent update_id, and stored. The original raw key is deleted. +fn migrate_raw_queue(state: &AppState, bot_fp: &str) { + let prefix = format!("queue:{}", bot_fp); + let mut keys_to_delete = Vec::new(); + + for item in state.db.messages.scan_prefix(prefix.as_bytes()) { + let (key, value) = match item { + Ok(pair) => pair, + Err(_) => continue, + }; + + let update = if let Ok(wire) = + bincode::deserialize::(&value) + { + wire_message_to_update(&wire, &value) + } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { + bot_json_to_update(&bot_msg) + } else { + None + }; + + if let Some(upd) = update { + enqueue_bot_update(state, bot_fp, upd); + } + keys_to_delete.push(key); + } + + for key in &keys_to_delete { + let _ = state.db.messages.remove(key); + } +} + +/// Convert a `WireMessage` into a Telegram-style update JSON (without update_id). +fn wire_message_to_update( + wire: &warzone_protocol::message::WireMessage, + raw_bytes: &[u8], +) -> Option { + match wire { + warzone_protocol::message::WireMessage::Message { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": null, + "raw_encrypted": raw_b64, + } + })) + } + warzone_protocol::message::WireMessage::KeyExchange { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": null, + "raw_encrypted": raw_b64, + } + })) + } + warzone_protocol::message::WireMessage::CallSignal { + id, + sender_fingerprint, + signal_type, + payload, + .. + } => Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": format!("/call_{:?}", signal_type), + "call_signal": { + "type": format!("{:?}", signal_type), + "payload": payload, + }, + } + })), + warzone_protocol::message::WireMessage::FileHeader { + id, + sender_fingerprint, + filename, + file_size, + .. + } => Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "document": { + "file_name": filename, + "file_size": file_size, + }, + } + })), + // Skip receipts and other variants. + warzone_protocol::message::WireMessage::Receipt { .. } => None, + _ => None, + } +} + +/// Convert a plaintext bot JSON message into a Telegram-style update (without update_id). +fn bot_json_to_update(bot_msg: &serde_json::Value) -> Option { + let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?; + match msg_type { + "bot_message" => Some(serde_json::json!({ + "message": { + "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), + "is_bot": true, + }, + "chat": { + "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), + "type": "private", + }, + "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), + "text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), + } + })), + "callback_query" => Some(serde_json::json!({ + "callback_query": { + "id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), + "is_bot": false, + }, + "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), + "message": bot_msg.get("message"), + } + })), + _ => None, + } +} + +/// Collect up to `limit` updates from `bot_queue::*`, preserving order. +fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec { + let prefix = format!("bot_queue:{}:", bot_fp); + let mut updates = Vec::new(); + for item in state.db.messages.scan_prefix(prefix.as_bytes()) { + let (_key, value) = match item { + Ok(pair) => pair, + Err(_) => continue, + }; + if let Ok(update) = serde_json::from_slice::(&value) { + updates.push(update); + if updates.len() >= limit { + break; + } + } + } + updates +} + +// --------------------------------------------------------------------------- +// sendMessage — enhanced with parse_mode, reply_to, reply_markup +// --------------------------------------------------------------------------- + #[derive(Deserialize)] struct SendMessageRequest { chat_id: String, text: String, + #[serde(default)] + parse_mode: Option, + #[serde(default)] + reply_to_message_id: Option, + #[serde(default)] + reply_markup: Option, } /// `POST /bot/:token/sendMessage` -- send a plaintext message to a user. @@ -383,7 +546,11 @@ async fn send_message( "type": "bot_message", "id": msg_id, "from": bot_fp, + "from_name": bot_info["name"], "text": req.text, + "parse_mode": req.parse_mode, + "reply_to_message_id": req.reply_to_message_id, + "reply_markup": req.reply_markup, "timestamp": chrono::Utc::now().timestamp(), }); let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default(); @@ -401,3 +568,216 @@ async fn send_message( } })) } + +// --------------------------------------------------------------------------- +// answerCallbackQuery +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct AnswerCallbackRequest { + callback_query_id: String, + #[serde(default)] + text: Option, + #[serde(default)] + show_alert: Option, +} + +/// `POST /bot/:token/answerCallbackQuery` -- acknowledge a callback query. +/// +/// In v1 this is a no-op acknowledgement; no popup is delivered to the client. +async fn answer_callback_query( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + if validate_bot_token(&state, &token).is_none() { + return Json(serde_json::json!({"ok": false, "description": "invalid token"})); + } + tracing::debug!( + "answerCallbackQuery id={} text={:?} alert={:?}", + req.callback_query_id, + req.text, + req.show_alert, + ); + Json(serde_json::json!({"ok": true, "result": true})) +} + +// --------------------------------------------------------------------------- +// editMessageText +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct EditMessageRequest { + chat_id: String, + message_id: String, + text: String, + #[serde(default)] + reply_markup: Option, +} + +/// `POST /bot/:token/editMessageText` -- edit a previously sent message. +async fn edit_message_text( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(i) => i, + None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), + }; + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); + let to_fp = req + .chat_id + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + + let edit_msg = serde_json::json!({ + "type": "bot_edit", + "id": req.message_id, + "from": bot_fp, + "text": req.text, + "reply_markup": req.reply_markup, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&edit_msg).unwrap_or_default(); + state.deliver_or_queue(&to_fp, &msg_bytes).await; + + Json(serde_json::json!({ + "ok": true, + "result": { + "message_id": req.message_id, + "chat": {"id": to_fp}, + "text": req.text, + "date": chrono::Utc::now().timestamp(), + } + })) +} + +// --------------------------------------------------------------------------- +// setWebhook / deleteWebhook / getWebhookInfo +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct SetWebhookRequest { + url: String, +} + +/// `POST /bot/:token/setWebhook` -- register a webhook URL for push delivery. +async fn set_webhook( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + let mut bot_info = match validate_bot_token(&state, &token) { + Some(i) => i, + None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), + }; + bot_info["webhook_url"] = serde_json::json!(req.url); + let key = format!("bot:{}", token); + let _ = state + .db + .tokens + .insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + tracing::info!("Bot webhook set: {}", req.url); + Json(serde_json::json!({"ok": true, "result": true, "description": "Webhook was set"})) +} + +/// `POST /bot/:token/deleteWebhook` -- remove a previously set webhook. +async fn delete_webhook( + State(state): State, + Path(token): Path, +) -> Json { + let mut bot_info = match validate_bot_token(&state, &token) { + Some(i) => i, + None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), + }; + bot_info.as_object_mut().map(|o| o.remove("webhook_url")); + let key = format!("bot:{}", token); + let _ = state + .db + .tokens + .insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + Json(serde_json::json!({"ok": true, "result": true, "description": "Webhook was deleted"})) +} + +/// `GET /bot/:token/getWebhookInfo` -- return current webhook configuration. +async fn get_webhook_info( + State(state): State, + Path(token): Path, +) -> Json { + match validate_bot_token(&state, &token) { + Some(info) => { + let url = info + .get("webhook_url") + .and_then(|v| v.as_str()) + .unwrap_or(""); + Json(serde_json::json!({ + "ok": true, + "result": { + "url": url, + "has_custom_certificate": false, + "pending_update_count": 0, + } + })) + } + None => Json(serde_json::json!({"ok": false, "description": "invalid token"})), + } +} + +// --------------------------------------------------------------------------- +// sendDocument +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct SendDocumentRequest { + chat_id: String, + /// File path, URL, or file_id reference. In v1, the reference is stored + /// and forwarded as-is without server-side file hosting. + document: String, + #[serde(default)] + caption: Option, +} + +/// `POST /bot/:token/sendDocument` -- send a document reference to a user. +async fn send_document( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(i) => i, + None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), + }; + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); + let to_fp = req + .chat_id + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + let msg_id = uuid::Uuid::new_v4().to_string(); + + let doc_msg = serde_json::json!({ + "type": "bot_document", + "id": msg_id, + "from": bot_fp, + "document": req.document, + "caption": req.caption, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&doc_msg).unwrap_or_default(); + let delivered = state.deliver_or_queue(&to_fp, &msg_bytes).await; + + Json(serde_json::json!({ + "ok": true, + "result": { + "message_id": msg_id, + "chat": {"id": to_fp}, + "document": {"file_name": req.document}, + "caption": req.caption, + "delivered": delivered, + } + })) +} diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 9775e9f..9b775b2 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -527,6 +527,45 @@ function connectWebSocket() { }; ws.onmessage = async (event) => { + if (typeof event.data === 'string') { + // Text frame — could be a bot message or missed call notification + try { + const json = JSON.parse(event.data); + if (json.type === 'missed_call') { + addSys('Missed call from ' + (json.data?.caller_fp || 'unknown')); + return; + } + if (json.type === 'bot_message') { + const botName = json.from_name || json.from || 'bot'; + let msgText = json.text || ''; + if (json.reply_markup && json.reply_markup.inline_keyboard) { + msgText += '\\n'; + for (const row of json.reply_markup.inline_keyboard) { + for (const btn of row) { + msgText += ' [' + btn.text + '] '; + } + msgText += '\\n'; + } + } + addMsg('@' + botName, msgText, false); + lastDmPeer = json.from ? normFP(json.from) : ''; + return; + } + if (json.type === 'bot_edit') { + addSys('[bot updated: ' + (json.text || '') + ']'); + return; + } + if (json.type === 'bot_document') { + addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + ']', false); + return; + } + } catch(e) {} + // If not JSON or unrecognized, try treating as binary + const bytes = new TextEncoder().encode(event.data); + dbg('WS text frame treated as bytes,', bytes.length, 'bytes'); + await handleIncomingMessage(bytes); + return; + } const bytes = new Uint8Array(event.data); dbg('WS received', bytes.length, 'bytes'); await handleIncomingMessage(bytes); @@ -628,12 +667,39 @@ async function handleIncomingMessage(bytes) { } } - // Last try: raw JSON file messages (from web file upload) + // Last try: raw JSON file messages (from web file upload) or bot messages try { const str = new TextDecoder().decode(bytes); const json = JSON.parse(str); if (json.type === 'file_header') { handleFileHeader(json); return; } if (json.type === 'file_chunk') { handleFileChunk(json); return; } + // Handle bot messages (plaintext JSON from bot API) + if (json.type === 'bot_message') { + const botName = json.from_name || json.from || 'bot'; + let msgText = json.text || ''; + // Handle inline keyboard if present + if (json.reply_markup && json.reply_markup.inline_keyboard) { + msgText += '\\n'; + for (const row of json.reply_markup.inline_keyboard) { + for (const btn of row) { + msgText += ' [' + btn.text + '] '; + } + msgText += '\\n'; + } + } + addMsg('@' + botName, msgText, false); + lastDmPeer = json.from ? normFP(json.from) : ''; + return; + } + if (json.type === 'bot_edit') { + addSys('[bot updated message: ' + (json.text || '') + ']'); + return; + } + if (json.type === 'bot_document') { + const caption = json.caption ? ' \u2014 ' + json.caption : ''; + addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + caption + ']', false); + return; + } } catch(e) {} dbg('ALL decrypt attempts failed'); diff --git a/warzone/docs/LLM_BOT_DEV.md b/warzone/docs/LLM_BOT_DEV.md new file mode 100644 index 0000000..b2275d5 --- /dev/null +++ b/warzone/docs/LLM_BOT_DEV.md @@ -0,0 +1,214 @@ +# featherChat Bot Development Reference + +## Setup + +Server: `http://HOST:7700` +All bot endpoints: `/v1/bot//METHOD` + +Register bot: +``` +POST /v1/bot/register +{"name":"MyBot","fingerprint":"any_32_hex_chars"} +→ {"ok":true,"result":{"token":"TOKEN","alias":"@mybot_bot"}} +``` + +Bot names must end with Bot/bot/_bot. Token format: `:`. + +## Endpoints + +### getMe +``` +GET /v1/bot/TOKEN/getMe +→ {"ok":true,"result":{"id":"fp","is_bot":true,"first_name":"MyBot","username":"MyBot"}} +``` + +### getUpdates (long-poll) +``` +POST /v1/bot/TOKEN/getUpdates +{"offset":LAST_UPDATE_ID+1,"timeout":30,"limit":100} +→ {"ok":true,"result":[{"update_id":N,"message":{...}}]} +``` + +offset: skip updates with id < offset (acknowledge processed) +timeout: long-poll seconds (max 30) +limit: max updates to return (default 100) + +### sendMessage +``` +POST /v1/bot/TOKEN/sendMessage +{ + "chat_id": "FINGERPRINT", + "text": "Hello!", + "parse_mode": "HTML", // optional + "reply_to_message_id": "MSG_ID", // optional + "reply_markup": { // optional, inline keyboard + "inline_keyboard": [ + [{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}] + ] + } +} +→ {"ok":true,"result":{"message_id":"UUID","delivered":true}} +``` + +### answerCallbackQuery +``` +POST /v1/bot/TOKEN/answerCallbackQuery +{"callback_query_id":"ID","text":"Done!","show_alert":false} +→ {"ok":true,"result":true} +``` + +### editMessageText +``` +POST /v1/bot/TOKEN/editMessageText +{"chat_id":"FP","message_id":"MSG_ID","text":"Updated text","reply_markup":{...}} +``` + +### sendDocument +``` +POST /v1/bot/TOKEN/sendDocument +{"chat_id":"FP","document":"filename_or_url","caption":"optional"} +``` + +### setWebhook / deleteWebhook / getWebhookInfo +``` +POST /v1/bot/TOKEN/setWebhook {"url":"https://mybot.example.com/webhook"} +POST /v1/bot/TOKEN/deleteWebhook +GET /v1/bot/TOKEN/getWebhookInfo +``` + +## Update Types + +Messages from users arrive in getUpdates as: + +**Plaintext (from other bots):** +```json +{"update_id":1,"message":{"message_id":"id","from":{"id":"fp","is_bot":true},"chat":{"id":"fp","type":"private"},"text":"Hello"}} +``` + +**Encrypted (from users with E2E sessions):** +```json +{"update_id":2,"message":{"message_id":"id","from":{"id":"fp","is_bot":false},"chat":{"id":"fp"},"text":null,"raw_encrypted":"base64..."}} +``` +Note: v1 bots cannot decrypt E2E messages. They see text=null + raw_encrypted blob. + +**Call signal:** +```json +{"update_id":3,"message":{"text":"/call_Offer","call_signal":{"type":"Offer","payload":"..."}}} +``` + +**File:** +```json +{"update_id":4,"message":{"document":{"file_name":"report.pdf","file_size":1234}}} +``` + +## Python Examples + +### Echo Bot +```python +import requests, time + +TOKEN = "YOUR_TOKEN" +API = f"http://localhost:7700/v1/bot/{TOKEN}" +offset = 0 + +while True: + resp = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json() + for update in resp.get("result", []): + offset = update["update_id"] + 1 + msg = update.get("message", {}) + chat_id = msg.get("chat", {}).get("id", "") + text = msg.get("text") + if text and chat_id: + requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"Echo: {text}"}) +``` + +### Inline Keyboard Bot +```python +import requests + +TOKEN = "YOUR_TOKEN" +API = f"http://localhost:7700/v1/bot/{TOKEN}" +offset = 0 + +def send_menu(chat_id): + requests.post(f"{API}/sendMessage", json={ + "chat_id": chat_id, + "text": "Choose an option:", + "reply_markup": { + "inline_keyboard": [ + [{"text": "Option A", "callback_data": "a"}, {"text": "Option B", "callback_data": "b"}], + [{"text": "Help", "callback_data": "help"}] + ] + } + }) + +while True: + resp = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json() + for update in resp.get("result", []): + offset = update["update_id"] + 1 + msg = update.get("message", {}) + text = msg.get("text", "") + chat_id = msg.get("chat", {}).get("id", "") + if text == "/start": + send_menu(chat_id) + elif text: + requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"You said: {text}"}) +``` + +### Node.js Echo Bot +```javascript +const API = `http://localhost:7700/v1/bot/${process.env.BOT_TOKEN}`; +let offset = 0; + +async function poll() { + while (true) { + try { + const res = await fetch(`${API}/getUpdates`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({offset, timeout: 30}) + }); + const data = await res.json(); + for (const update of data.result || []) { + offset = update.update_id + 1; + const msg = update.message; + if (msg?.text && msg?.chat?.id) { + await fetch(`${API}/sendMessage`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({chat_id: msg.chat.id, text: `Echo: ${msg.text}`}) + }); + } + } + } catch (e) { console.error(e); await new Promise(r => setTimeout(r, 3000)); } + } +} +poll(); +``` + +## Differences from Telegram + +| Feature | Telegram | featherChat | +|---------|----------|------------| +| chat_id | numeric | hex fingerprint string | +| getUpdates timeout | up to 50s | up to 30s | +| User messages | plaintext | E2E encrypted (text=null in v1) | +| Bot messages | plaintext | plaintext (no E2E) | +| File upload | multipart form | JSON reference (v1) | +| Inline keyboards | full support | stored + delivered, no popup | +| Callback queries | full popup | acknowledged, no popup | +| Webhooks | full HTTPS | URL stored, delivery planned | +| Media groups | supported | not yet | +| parse_mode | renders HTML/MD | stored, not rendered (v1) | + +## Key Patterns + +**Always use offset** — without it, the same messages are returned every poll. + +**chat_id is the sender's fingerprint** — use `msg.chat.id` or `msg.from.id`. + +**Bot alias** — users message bots via `@mybot_bot` which resolves to the bot's fingerprint. + +**Error handling** — all responses have `{"ok": bool}`. Check `ok` before accessing `result`. + +**Rate limits** — 200 concurrent server requests, no per-bot limit (be reasonable). From 3efce2ddf4599a8679ff948e6b9717168b98ebeb Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 08:11:31 +0400 Subject: [PATCH 08/50] v0.0.22: version bump, ETH identity in web client, version bump rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version: - Workspace + protocol: 0.0.21 → 0.0.22 - Web client VERSION: 0.0.17 → 0.0.22 - Service worker cache: wz-v2 → wz-v3 ETH identity: - Added WasmIdentity::eth_address() export (derives from seed via secp256k1) - Web client sends eth_address during key registration - Identity display shows ETH address first, then fingerprint - No more server-side resolve needed — computed client-side CLAUDE.md: - Added MANDATORY version bump rule (4 places to update) - Must bump on every functional change, never skip SW cache Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/CLAUDE.md | 10 +++++++ warzone/Cargo.lock | 10 +++---- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 27 ++++++++----------- warzone/crates/warzone-wasm/src/lib.rs | 6 +++++ 6 files changed, 34 insertions(+), 23 deletions(-) diff --git a/warzone/CLAUDE.md b/warzone/CLAUDE.md index 5b982e8..0a3c885 100644 --- a/warzone/CLAUDE.md +++ b/warzone/CLAUDE.md @@ -1,5 +1,15 @@ # featherChat — Design Principles & Conventions +## MANDATORY: Version Bumping + +**After every set of changes that modifies functionality, bump the version:** +1. `Cargo.toml` workspace version (e.g. `0.0.22` → `0.0.23`) +2. `crates/warzone-protocol/Cargo.toml` standalone version (same) +3. `crates/warzone-server/src/routes/web.rs` JS `VERSION` constant +4. `crates/warzone-server/src/routes/web.rs` service worker `CACHE` version (`wz-vN` → `wz-v(N+1)`) + +Never commit functional changes without bumping all four. The service worker cache MUST be bumped or browsers will serve stale WASM. + ## Architecture Principles 1. **Single seed, multiple identities** — Ed25519 (messaging), X25519 (encryption), secp256k1 (ETH address) all derived from one BIP39 seed via HKDF with domain-separated info strings. diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index f2bd0a8..27cffb7 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.21" +version = "0.0.22" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.21" +version = "0.0.22" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.21" +version = "0.0.22" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.21" +version = "0.0.22" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.21" +version = "0.0.22" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index da33c53..132b47a 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.21" +version = "0.0.22" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 5a525de..b118c06 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.21" +version = "0.0.22" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 9b775b2..5ee6af0 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v2'; +const CACHE = 'wz-v3'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -242,7 +242,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.17'; +const VERSION = '0.0.22'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -388,11 +388,12 @@ function loadSavedIdentity() { async function registerKey() { const fp = normFP(myFingerprint); const bundleBytes = wasmIdentity.bundle_bytes(); - dbg('Registering key, fp:', fp, 'bundle size:', bundleBytes.length); + myEthAddress = wasmIdentity.eth_address(); + dbg('Registering key, fp:', fp, 'bundle size:', bundleBytes.length, 'eth:', myEthAddress); await fetch(SERVER + '/v1/keys/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fingerprint: fp, bundle: Array.from(bundleBytes) }) + body: JSON.stringify({ fingerprint: fp, bundle: Array.from(bundleBytes), eth_address: myEthAddress }) }); dbg('Key registered'); } @@ -862,20 +863,14 @@ async function enterChat() { document.getElementById('hdr-server').textContent = SERVER; await registerKey(); - addSys('Identity loaded: ' + myFingerprint); + addSys('Identity: ' + myEthAddress); + addSys('Fingerprint: ' + myFingerprint); addSys('Key registered with server'); - // Fetch ETH address from server - try { - const resolveResp = await fetch(SERVER + '/v1/resolve/' + normFP(myFingerprint)); - const resolveData = await resolveResp.json(); - if (resolveData.eth_address) { - myEthAddress = resolveData.eth_address; - addSys('ETH: ' + myEthAddress); - document.getElementById('hdr-eth').textContent = myEthAddress.slice(0, 10) + '...'; - document.getElementById('hdr-eth').title = myEthAddress; - } - } catch(e) { dbg('ETH resolve failed:', e); } + if (myEthAddress) { + document.getElementById('hdr-eth').textContent = myEthAddress.slice(0, 10) + '...'; + document.getElementById('hdr-eth').title = myEthAddress; + } addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above'); addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info'); diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs index 879393d..0c84ae4 100644 --- a/warzone/crates/warzone-wasm/src/lib.rs +++ b/warzone/crates/warzone-wasm/src/lib.rs @@ -52,6 +52,12 @@ impl WasmIdentity { Seed::from_bytes(self.seed_bytes).to_mnemonic() } + /// Get the Ethereum address derived from this seed. + pub fn eth_address(&self) -> String { + let eth = warzone_protocol::ethereum::derive_eth_identity(&self.seed_bytes); + eth.address.to_checksum() + } + /// Get the pre-key bundle as bincode bytes (for server registration). /// The bundle is generated once and cached. The SPK secret is stored internally. pub fn bundle_bytes(&mut self) -> Result, JsValue> { From 2aa58a4319a69b0bd6e1504d3d62340f7055d455 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 08:20:38 +0400 Subject: [PATCH 09/50] fix: TUI shows ETH address, /peer 0x... resolves, Cmd+key on macOS TUI header: shows ETH address (0x...) instead of fingerprint /peer 0x...: resolves via GET /v1/resolve/:address endpoint Cmd+A/E/U/K/W: macOS SUPER modifier now handled alongside CONTROL Added resolve_address() method for ETH/any address resolution Co-Authored-By: Claude Opus 4.6 (1M context) --- .../crates/warzone-client/src/tui/commands.rs | 29 +++++++++++++++++++ warzone/crates/warzone-client/src/tui/draw.rs | 7 ++++- .../crates/warzone-client/src/tui/input.rs | 18 ++++++------ .../crates/warzone-client/src/tui/types.rs | 11 +++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 9236200..1bcc431 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -379,6 +379,12 @@ impl App { Some(resolved) => resolved, None => return, } + } else if raw.starts_with("0x") || raw.starts_with("0X") { + // Resolve ETH address via server + match self.resolve_address(&raw, client).await { + Some(resolved) => resolved, + None => return, + } } else { raw }; @@ -928,6 +934,29 @@ impl App { } } + /// Resolve an ETH address (0x...) or any address format via the server. + pub(crate) async fn resolve_address(&self, addr: &str, client: &ServerClient) -> Option { + let url = format!("{}/v1/resolve/{}", client.base_url, addr); + 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!("{} → {}", addr, &fp[..fp.len().min(16)]), 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!("Cannot resolve {}: {}", addr, 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 { diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs index 685659c..fb75fe3 100644 --- a/warzone/crates/warzone-client/src/tui/draw.rs +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -58,9 +58,14 @@ impl App { } else { (" \u{25CF}", Color::Red) // ● }; + let identity_display = if self.our_eth.is_empty() { + self.our_fp.clone() + } else { + format!("{}", &self.our_eth[..self.our_eth.len().min(12)]) + }; 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::styled(identity_display, Style::default().fg(Color::Green)), Span::raw(" \u{2192} "), Span::styled(peer_str, Style::default().fg(Color::Yellow)), Span::styled( diff --git a/warzone/crates/warzone-client/src/tui/input.rs b/warzone/crates/warzone-client/src/tui/input.rs index 60bde52..fbb9224 100644 --- a/warzone/crates/warzone-client/src/tui/input.rs +++ b/warzone/crates/warzone-client/src/tui/input.rs @@ -51,9 +51,9 @@ impl App { self.cursor_pos += 1; } } - // Home / Ctrl+A + // Home / Ctrl+A / Cmd+A (macOS) KeyCode::Home => { self.cursor_pos = 0; } - KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { self.cursor_pos = 0; } // End: cursor to end of input when typing, snap to bottom when input is empty. @@ -70,20 +70,20 @@ impl App { self.cursor_pos = self.input.len(); } } - KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { self.cursor_pos = self.input.len(); } - // Ctrl+U: clear line - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Ctrl+U / Cmd+U: clear line + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { self.input.clear(); self.cursor_pos = 0; } - // Ctrl+K: kill to end of line - KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Ctrl+K / Cmd+K: kill to end of line + KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { self.input.truncate(self.cursor_pos); } - // Ctrl+W: delete word back - KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Ctrl+W / Cmd+W: delete word back + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => { 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); diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs index 17bb889..518d94a 100644 --- a/warzone/crates/warzone-client/src/tui/types.rs +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -41,6 +41,8 @@ pub struct App { pub receipts: Arc>>, /// Pending incoming file transfers, keyed by file ID. pub pending_files: Arc>>, + /// Our ETH address (derived from seed). + pub our_eth: String, /// Scroll offset from bottom (0 = pinned to newest). pub scroll_offset: usize, /// Whether the WebSocket connection is active. @@ -99,6 +101,14 @@ impl App { timestamp: Local::now(), }); + // Derive ETH address from seed if available + let our_eth = crate::keystore::load_seed_raw() + .map(|seed| { + let eth = warzone_protocol::ethereum::derive_eth_identity(&seed); + eth.address.to_checksum() + }) + .unwrap_or_default(); + App { input: String::new(), messages, @@ -110,6 +120,7 @@ impl App { cursor_pos: 0, receipts: Arc::new(Mutex::new(HashMap::new())), pending_files: Arc::new(Mutex::new(HashMap::new())), + our_eth, scroll_offset: 0, connected: Arc::new(AtomicBool::new(false)), } From ea04405199ee4e3659c433981242ef56a85f953e Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 08:50:31 +0400 Subject: [PATCH 10/50] v0.0.23: ETH display everywhere, local build, web UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version: 0.0.22 → 0.0.23, SW cache wz-v3 → wz-v4 TUI: - Own messages show ETH address (0x...) instead of fingerprint - Received messages: async ETH cache lookup (resolve on first sight) - /info shows Identity + Fingerprint - Welcome message shows ETH address Web: - Header shows only ETH address (single element, click to copy) - Own messages show ETH format - Received messages resolve sender ETH via /v1/resolve/ - /peer 0x... resolves via /v1/resolve/ endpoint - Click messages area → focuses text input Client: - register_bundle sends eth_address to server - ETH↔fingerprint mapping stored on registration Build: - --local: build on current machine (auto-detect apt/dnf/pacman/brew) - --local-ship: build locally + deploy to all servers - --local-clean: build + clean cargo cache Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.toml | 2 +- warzone/crates/warzone-client/src/cli/init.rs | 10 +- warzone/crates/warzone-client/src/net.rs | 4 + .../crates/warzone-client/src/tui/commands.rs | 15 +-- .../crates/warzone-client/src/tui/network.rs | 47 ++++++- .../crates/warzone-client/src/tui/types.rs | 20 +-- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 59 +++++---- warzone/scripts/build-linux.sh | 122 +++++++++++++++++- 9 files changed, 226 insertions(+), 55 deletions(-) diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 132b47a..6ae20ae 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.22" +version = "0.0.23" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/cli/init.rs b/warzone/crates/warzone-client/src/cli/init.rs index ee2292d..b571431 100644 --- a/warzone/crates/warzone-client/src/cli/init.rs +++ b/warzone/crates/warzone-client/src/cli/init.rs @@ -85,8 +85,16 @@ pub async fn register_with_server_identity( .map_err(|_| anyhow::anyhow!("No bundle found. Run `warzone init` first."))?; let bundle: PreKeyBundle = bincode::deserialize(&bundle_bytes)?; + // Derive ETH address from seed + let eth_address = crate::keystore::load_seed_raw() + .map(|seed| { + let eth = warzone_protocol::ethereum::derive_eth_identity(&seed); + eth.address.to_checksum() + }) + .ok(); + let client = ServerClient::new(server_url); - client.register_bundle(&fp, &bundle).await?; + client.register_bundle(&fp, &bundle, eth_address).await?; println!("Bundle registered with {}", server_url); Ok(()) diff --git a/warzone/crates/warzone-client/src/net.rs b/warzone/crates/warzone-client/src/net.rs index 79184f5..4648c0d 100644 --- a/warzone/crates/warzone-client/src/net.rs +++ b/warzone/crates/warzone-client/src/net.rs @@ -14,6 +14,8 @@ pub struct ServerClient { struct RegisterRequest { fingerprint: String, bundle: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + eth_address: Option, } #[derive(Serialize)] @@ -43,6 +45,7 @@ impl ServerClient { &self, fingerprint: &str, bundle: &PreKeyBundle, + eth_address: Option, ) -> Result<()> { let encoded = bincode::serialize(bundle).context("failed to serialize bundle")?; @@ -51,6 +54,7 @@ impl ServerClient { .json(&RegisterRequest { fingerprint: fingerprint.to_string(), bundle: encoded, + eth_address, }) .send() .await diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 1bcc431..50fa676 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -34,13 +34,10 @@ impl App { 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(), - }); + if !self.our_eth.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: format!("Identity: {}", self.our_eth), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + self.add_message(ChatLine { sender: "system".into(), text: format!("Fingerprint: {}", self.our_fp), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; } if text == "/help" || text == "/?" { @@ -634,7 +631,7 @@ impl App { 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(), + sender: if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { format!("{}...", &self.our_eth[..self.our_eth.len().min(12)]) }, text: text.clone(), is_system: false, is_self: true, @@ -879,7 +876,7 @@ impl App { { Ok(_) => { self.add_message(ChatLine { - sender: format!("{} [#{}]", &self.our_fp[..12], group_name), + sender: format!("{} [#{}]", if self.our_eth.is_empty() { &self.our_fp[..12] } else { &self.our_eth[..self.our_eth.len().min(12)] }, group_name), text: text.to_string(), is_system: false, is_self: true, diff --git a/warzone/crates/warzone-client/src/tui/network.rs b/warzone/crates/warzone-client/src/tui/network.rs index c2dfb42..47eacce 100644 --- a/warzone/crates/warzone-client/src/tui/network.rs +++ b/warzone/crates/warzone-client/src/tui/network.rs @@ -43,6 +43,38 @@ fn send_receipt( }); } +/// ETH address cache: fingerprint → ETH address (populated async, read sync). +pub type EthCache = Arc>>; + +/// Display a fingerprint as short ETH address if cached, otherwise truncated fingerprint. +fn display_sender(fp: &str, eth_cache: &EthCache) -> String { + let cache = eth_cache.lock().unwrap(); + if let Some(eth) = cache.get(fp) { + format!("{}...", ð[..eth.len().min(12)]) + } else { + fp[..fp.len().min(12)].to_string() + } +} + +/// Async: look up ETH address for a fingerprint and cache it. +fn cache_eth_lookup(fp: &str, client: &ServerClient, eth_cache: &EthCache) { + let fp = fp.to_string(); + let client = client.clone(); + let cache = eth_cache.clone(); + // Check if already cached + if cache.lock().unwrap().contains_key(&fp) { return; } + tokio::spawn(async move { + let url = format!("{}/v1/resolve/{}", client.base_url, fp); + if let Ok(resp) = client.client.get(&url).send().await { + if let Ok(data) = resp.json::().await { + if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) { + cache.lock().unwrap().insert(fp, eth.to_string()); + } + } + } + }); +} + 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); @@ -58,10 +90,11 @@ pub fn process_incoming( pending_files: &Arc>>, our_fp: &str, client: &ServerClient, + eth_cache: &EthCache, 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), + Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer, eth_cache), Err(_) => {} } } @@ -76,6 +109,7 @@ fn process_wire_message( our_fp: &str, client: &ServerClient, last_dm_peer: &Arc>>, + eth_cache: &EthCache, ) { match wire { WireMessage::KeyExchange { @@ -117,7 +151,7 @@ fn process_wire_message( } store_received(db, &sender_fingerprint, &text); messages.lock().unwrap().push(ChatLine { - sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), + sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) }, text, is_system: false, is_self: false, @@ -166,7 +200,7 @@ fn process_wire_message( } store_received(db, &sender_fingerprint, &text); messages.lock().unwrap().push(ChatLine { - sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), + sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) }, text, is_system: false, is_self: false, @@ -464,7 +498,7 @@ fn process_wire_message( } => { let type_str = format!("{:?}", signal_type); messages.lock().unwrap().push(ChatLine { - sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), + sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) }, text: format!("\u{1f4de} Call signal: {}", type_str), is_system: false, is_self: false, @@ -487,6 +521,7 @@ pub async fn poll_loop( connected: Arc, ) { let fp = normfp(&our_fp); + let eth_cache: EthCache = Arc::new(std::sync::Mutex::new(HashMap::new())); // Try WebSocket first let ws_url = client.base_url @@ -511,7 +546,7 @@ pub async fn poll_loop( 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); + process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer); } } @@ -534,7 +569,7 @@ pub async fn poll_loop( Err(_) => continue, }; for raw in &raw_msgs { - process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer); + process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer); } } } diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs index 518d94a..ec2de23 100644 --- a/warzone/crates/warzone-client/src/tui/types.rs +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -63,9 +63,19 @@ pub struct ChatLine { impl App { pub fn new(our_fp: String, peer_fp: Option, server_url: String) -> Self { + // Derive ETH address from seed first (used in welcome messages) + let our_eth = crate::keystore::load_seed_raw() + .map(|seed| { + let eth = warzone_protocol::ethereum::derive_eth_identity(&seed); + eth.address.to_checksum() + }) + .unwrap_or_default(); + + let identity_display = if our_eth.is_empty() { our_fp.clone() } else { our_eth.clone() }; + let messages = Arc::new(Mutex::new(vec![ChatLine { sender: "system".into(), - text: format!("You are {}", our_fp), + text: format!("You are {}", identity_display), is_system: true, is_self: false, message_id: None, @@ -101,14 +111,6 @@ impl App { timestamp: Local::now(), }); - // Derive ETH address from seed if available - let our_eth = crate::keystore::load_seed_raw() - .map(|seed| { - let eth = warzone_protocol::ethereum::derive_eth_identity(&seed); - eth.address.to_checksum() - }) - .unwrap_or_default(); - App { input: String::new(), messages, diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index b118c06..5b08ee6 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.22" +version = "0.0.23" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 5ee6af0..dbabbe2 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v3'; +const CACHE = 'wz-v4'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -209,8 +209,8 @@ const WEB_HTML: &str = r##"
- - + + @@ -242,7 +242,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.22'; +const VERSION = '0.0.23'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -616,13 +616,16 @@ async function handleIncomingMessage(bytes) { let fromLabel = result.sender.slice(0, 19); try { - const ar = await fetch(SERVER + '/v1/alias/whois/' + senderFP); + const ar = await fetch(SERVER + '/v1/resolve/' + senderFP); const ad = await ar.json(); - if (ad.alias) fromLabel = '@' + ad.alias; + if (ad.eth_address) fromLabel = ad.eth_address.slice(0, 12) + '...'; + // Alias overrides ETH + const aw = await fetch(SERVER + '/v1/alias/whois/' + senderFP); + const adata = await aw.json(); + if (adata.alias) fromLabel = '@' + adata.alias; } catch(e) {} addMsg(fromLabel, result.text, false); - // Send delivery receipt if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered'); lastDmPeer = normFP(result.sender); return; @@ -653,13 +656,16 @@ async function handleIncomingMessage(bytes) { let fromLabel = result.sender.slice(0, 19); try { - const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender)); + const rfp = normFP(result.sender); + const ar = await fetch(SERVER + '/v1/resolve/' + rfp); const ad = await ar.json(); - if (ad.alias) fromLabel = '@' + ad.alias; + if (ad.eth_address) fromLabel = ad.eth_address.slice(0, 12) + '...'; + const aw = await fetch(SERVER + '/v1/alias/whois/' + rfp); + const adata = await aw.json(); + if (adata.alias) fromLabel = '@' + adata.alias; } catch(e2) {} addMsg(fromLabel, result.text, false); - // Send delivery receipt if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered'); lastDmPeer = normFP(result.sender); return; @@ -859,18 +865,21 @@ let pendingFiles = {}; // file_id -> { filename, chunks: [], total, received, async function enterChat() { document.getElementById('setup').classList.remove('active'); document.getElementById('chat').classList.add('active'); - document.getElementById('hdr-fp').textContent = myFingerprint.slice(0, 19); document.getElementById('hdr-server').textContent = SERVER; await registerKey(); - addSys('Identity: ' + myEthAddress); - addSys('Fingerprint: ' + myFingerprint); - addSys('Key registered with server'); - + // Show ETH in header, fallback to fingerprint + const hdrFp = document.getElementById('hdr-fp'); if (myEthAddress) { - document.getElementById('hdr-eth').textContent = myEthAddress.slice(0, 10) + '...'; - document.getElementById('hdr-eth').title = myEthAddress; + hdrFp.textContent = myEthAddress.slice(0, 12) + '...'; + hdrFp.title = myEthAddress; + hdrFp.onclick = function() { navigator.clipboard.writeText(myEthAddress); addSys('Copied: ' + myEthAddress); }; + } else { + hdrFp.textContent = (myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)); + hdrFp.title = myFingerprint; } + addSys('Identity: ' + (myEthAddress || myFingerprint)); + addSys('Key registered with server'); addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above'); addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info'); @@ -986,7 +995,7 @@ async function sendToGroup(groupName, text) { body: JSON.stringify({ from: myFP, messages }) }); - addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true, null); + addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)) + ' [' + groupName + ']', text, true, null); } // ── Send handler ── @@ -1092,18 +1101,19 @@ async function doSend() { $peerInput.value = lastDmPeer; try { await sendEncrypted(lastDmPeer, replyText.trim()); - addMsg(myFingerprint.slice(0, 19), replyText.trim(), true); + addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)), replyText.trim(), true); } catch(e) { addSys('Reply failed: ' + e.message); } return; } if (text.startsWith('/p ') || text.startsWith('/peer ')) { let val = text.startsWith('/p ') ? text.slice(3).trim() : text.slice(6).trim(); - if (val.startsWith('@')) { - const resp = await fetch(SERVER + '/v1/alias/resolve/' + val.slice(1)); + if (val.startsWith('@') || val.startsWith('0x') || val.startsWith('0X')) { + const endpoint = val.startsWith('@') ? '/v1/alias/resolve/' + val.slice(1) : '/v1/resolve/' + val; + const resp = await fetch(SERVER + endpoint); const data = await resp.json(); - if (data.error) { addSys('Unknown alias ' + val); return; } + if (data.error) { addSys('Cannot resolve ' + val + ': ' + data.error); return; } $peerInput.value = data.fingerprint; - addSys(val + ' → ' + data.fingerprint.slice(0,16) + '...'); + addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...'); } else { $peerInput.value = val; } @@ -1197,7 +1207,7 @@ async function doSend() { try { const msgId = await sendEncrypted(peer, text); sentMsgReceipts[msgId] = { status: 'sent', el: null }; - addMsg(myFingerprint.slice(0, 19), text, true, msgId); + addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)), text, true, msgId); } catch(e) { addSys('Send failed: ' + e.message); } @@ -1218,6 +1228,7 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB document.getElementById('btn-recover').onclick = () => doRecover(); document.getElementById('btn-enter').onclick = () => enterChat(); document.getElementById('send-btn').onclick = () => doSend(); +document.getElementById('messages').onclick = () => document.getElementById('msg-input').focus(); document.getElementById('hdr-eth').onclick = function() { if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address')); }; diff --git a/warzone/scripts/build-linux.sh b/warzone/scripts/build-linux.sh index 7b440ff..e819d00 100755 --- a/warzone/scripts/build-linux.sh +++ b/warzone/scripts/build-linux.sh @@ -384,6 +384,108 @@ do_logs() { ssh "$host" "journalctl -u $PROD_SERVICE -f --no-pager" } +# --------------------------------------------------------------------------- +# --local: Build locally on this machine (auto-detect package manager) +# --------------------------------------------------------------------------- + +detect_pkg_manager() { + if command -v apt-get &>/dev/null; then echo "apt" + elif command -v dnf &>/dev/null; then echo "dnf" + elif command -v pacman &>/dev/null; then echo "pacman" + elif command -v brew &>/dev/null; then echo "brew" + else echo "unknown"; fi +} + +do_local_deps() { + local pm + pm=$(detect_pkg_manager) + echo "[1/4] Installing dependencies ($pm)..." + + case "$pm" in + apt) + sudo apt-get update -qq + sudo apt-get install -y -qq build-essential pkg-config libssl-dev curl >/dev/null 2>&1 + ;; + dnf) + sudo dnf install -y gcc gcc-c++ make pkg-config openssl-devel curl >/dev/null 2>&1 + ;; + pacman) + sudo pacman -Sy --noconfirm base-devel pkg-config openssl curl >/dev/null 2>&1 + ;; + brew) + brew install openssl pkg-config 2>/dev/null || true + ;; + *) + echo "WARNING: Unknown package manager. Ensure build-essential, pkg-config, libssl-dev are installed." + ;; + esac + + # Ensure Rust is installed + if ! command -v cargo &>/dev/null; then + echo " Installing Rust..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + source "$HOME/.cargo/env" + fi + + # Ensure wasm-pack is installed + if ! command -v wasm-pack &>/dev/null; then + echo " Installing wasm-pack..." + cargo install wasm-pack 2>/dev/null || true + fi + + # Ensure wasm target + rustup target add wasm32-unknown-unknown 2>/dev/null || true +} + +do_local_build() { + local arch + arch=$(uname -m) + local os + os=$(uname -s | tr '[:upper:]' '[:lower:]') + local out_dir="target/${os}-${arch}" + + echo "=== Local Build (${os}-${arch}) ===" + + do_local_deps + + echo "[2/4] Building WASM..." + wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg 2>&1 | tail -3 + + echo "[3/4] Building release binaries..." + cargo build --release --bin warzone-server --bin warzone-client 2>&1 + + echo "[4/4] Copying to ${out_dir}..." + mkdir -p "$out_dir" + cp target/release/warzone-server target/release/warzone-client "$out_dir/" + cp federation.example.json "$out_dir/" 2>/dev/null || true + + # Clean cargo cache if requested + if [ "${CLEAN_CACHE:-}" = "1" ]; then + echo " Cleaning build cache..." + cargo clean 2>/dev/null || true + fi + + echo "" + echo "=== Local Build Complete ===" + ls -lh "$out_dir"/warzone-* + echo "" + echo "Run:" + echo " $out_dir/warzone-server --bind 0.0.0.0:7700" + echo " $out_dir/warzone-client tui --server http://localhost:7700" +} + +do_local_ship() { + do_local_build + echo "" + do_update_all + echo "" + do_status + echo "" + echo "========================================" + echo " LOCAL SHIP COMPLETE" + echo "========================================" +} + # --------------------------------------------------------------------------- # --ship: Build + deploy to all servers + destroy VM (full pipeline) # --------------------------------------------------------------------------- @@ -453,18 +555,30 @@ case "${1:-}" in --ship) do_ship ;; + --local) + do_local_build + ;; + --local-ship) + do_local_ship + ;; + --local-clean) + CLEAN_CACHE=1 do_local_build + ;; --upload) do_upload ;; *) echo "Usage: $0 [args]" echo "" - echo "One command:" - echo " --ship Build + deploy to all servers + destroy VM" + echo "Local build:" + echo " --local Build locally (auto-detect OS, install deps)" + echo " --local-ship Build locally + deploy to all servers" + echo " --local-clean Build locally + clean cargo cache after" echo "" - echo "Build (Hetzner VM):" + echo "Remote build (Hetzner VM):" + echo " --ship Build on VM + deploy + destroy VM" echo " --prepare Create VM, install deps, upload source" - echo " --build Build release binaries" + echo " --build Build release binaries on VM" echo " --transfer Download binaries to $OUTPUT_DIR" echo " --destroy Delete the build VM" echo " --all prepare + build + transfer (VM persists)" From 1851728a099c1d74a997e61ca8675822edd500f3 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 09:04:37 +0400 Subject: [PATCH 11/50] v0.0.24: ETH display in TUI header/messages, web peer resolve, click-focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI: - Header shows peer ETH address (resolved on /peer set) - Own messages show ETH format - Resolve display shows full formatted fingerprint (xxxx:xxxx:...) - peer_eth field stored on App for header display Web: - Pasting 0x address in peer input box now resolves via /v1/resolve/ - Send path resolves 0x/@ before encrypting - Click messages area → focuses text input - Own messages show ETH format Version: 0.0.23 → 0.0.24, SW cache wz-v4 → wz-v5 Build script: --local, --local-ship, --local-clean commands Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 ++++---- warzone/Cargo.toml | 2 +- .../crates/warzone-client/src/tui/commands.rs | 23 +++++++++++++++++-- warzone/crates/warzone-client/src/tui/draw.rs | 10 ++++---- .../crates/warzone-client/src/tui/types.rs | 3 +++ warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 13 +++++------ 7 files changed, 43 insertions(+), 20 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 27cffb7..d1fb3e9 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.22" +version = "0.0.24" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.22" +version = "0.0.24" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.22" +version = "0.0.24" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.22" +version = "0.0.24" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.22" +version = "0.0.24" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 6ae20ae..1167562 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.23" +version = "0.0.24" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 50fa676..fe7c4be 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -371,6 +371,8 @@ impl App { 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 is_eth_input = raw.starts_with("0x") || raw.starts_with("0X"); + let eth_input = if is_eth_input { Some(raw.clone()) } else { None }; let fp = if raw.starts_with('@') { match self.resolve_alias(&raw[1..], client).await { Some(resolved) => resolved, @@ -389,9 +391,22 @@ impl App { self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; } + // Resolve peer ETH for display + if is_eth_input { + self.peer_eth = eth_input; + } else { + // Try to look up ETH for this fingerprint + let resolve_url = format!("{}/v1/resolve/{}", client.base_url, normfp(&fp)); + if let Ok(resp) = client.client.get(&resolve_url).send().await { + if let Ok(data) = resp.json::().await { + self.peer_eth = data.get("eth_address").and_then(|v| v.as_str()).map(String::from); + } + } + } + let display = self.peer_eth.as_deref().unwrap_or(&fp); self.add_message(ChatLine { sender: "system".into(), - text: format!("Peer set to {}", fp), + text: format!("Peer set to {}", display), is_system: true, is_self: false, message_id: None, timestamp: Local::now(), @@ -938,7 +953,11 @@ impl App { 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!("{} → {}", addr, &fp[..fp.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + // Format fingerprint with colons: xxxx:xxxx:xxxx:... + let formatted: String = fp.chars().enumerate() + .flat_map(|(i, c)| if i > 0 && i % 4 == 0 { vec![':', c] } else { vec![c] }) + .collect(); + self.add_message(ChatLine { sender: "system".into(), text: format!("{} → {}", addr, formatted), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return Some(fp.to_string()); } if let Some(err) = data.get("error") { diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs index fb75fe3..94a8309 100644 --- a/warzone/crates/warzone-client/src/tui/draw.rs +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -48,10 +48,12 @@ impl App { .split(frame.area()); // Header - let peer_str = self - .peer_fp - .as_deref() - .unwrap_or("no peer"); + let peer_str = match (&self.peer_eth, &self.peer_fp) { + (Some(eth), _) => format!("{}...", ð[..eth.len().min(12)]), + (None, Some(fp)) => fp.clone(), + (None, None) => "no peer".to_string(), + }; + let peer_str = peer_str.as_str(); let is_connected = self.connected.load(Ordering::Relaxed); let (conn_indicator, conn_color) = if is_connected { (" \u{25CF}", Color::Green) // ● diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs index ec2de23..5782eab 100644 --- a/warzone/crates/warzone-client/src/tui/types.rs +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -43,6 +43,8 @@ pub struct App { pub pending_files: Arc>>, /// Our ETH address (derived from seed). pub our_eth: String, + /// Current peer's ETH address (resolved on /peer set). + pub peer_eth: Option, /// Scroll offset from bottom (0 = pinned to newest). pub scroll_offset: usize, /// Whether the WebSocket connection is active. @@ -123,6 +125,7 @@ impl App { receipts: Arc::new(Mutex::new(HashMap::new())), pending_files: Arc::new(Mutex::new(HashMap::new())), our_eth, + peer_eth: None, scroll_offset: 0, connected: Arc::new(AtomicBool::new(false)), } diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 5b08ee6..56cd3cf 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.23" +version = "0.0.24" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index dbabbe2..a98ac67 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v4'; +const CACHE = 'wz-v5'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -242,7 +242,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.23'; +const VERSION = '0.0.24'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -1193,13 +1193,12 @@ async function doSend() { let peer = $peerInput.value.trim(); if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint/@alias or use /g '); return; } - if (peer.startsWith('@')) { - const aliasName = peer.slice(1); - const resp = await fetch(SERVER + '/v1/alias/resolve/' + aliasName); + if (peer.startsWith('@') || peer.startsWith('0x') || peer.startsWith('0X')) { + const endpoint = peer.startsWith('@') ? '/v1/alias/resolve/' + peer.slice(1) : '/v1/resolve/' + peer; + const resp = await fetch(SERVER + endpoint); const data = await resp.json(); - if (data.error) { addSys('Unknown alias @' + aliasName); return; } + if (data.error) { addSys('Cannot resolve ' + peer + ': ' + data.error); return; } peer = data.fingerprint; - addSys('Resolved @' + aliasName + ' → ' + peer.slice(0,16) + '...'); } localStorage.setItem('wz-peer', $peerInput.value.trim()); From 0697c988fa90327eacefa2587b610aeb8d8f52aa Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 09:06:29 +0400 Subject: [PATCH 12/50] fix: build-linux.sh --local cd to project root before building Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/scripts/build-linux.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/warzone/scripts/build-linux.sh b/warzone/scripts/build-linux.sh index e819d00..d0653a9 100755 --- a/warzone/scripts/build-linux.sh +++ b/warzone/scripts/build-linux.sh @@ -438,6 +438,13 @@ do_local_deps() { } do_local_build() { + # cd to project root (script may be run from scripts/ or project root) + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local project_root="$(dirname "$script_dir")" + cd "$project_root" + echo " Project root: $(pwd)" + local arch arch=$(uname -m) local os From deb220ff2ca33c66b7355d989e13a54c29182889 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 09:11:15 +0400 Subject: [PATCH 13/50] fix: SW uses network-first strategy, updates apply without clearing storage Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-server/src/routes/web.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index a98ac67..3dbf505 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v5'; +const CACHE = 'wz-v6'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -71,18 +71,17 @@ self.addEventListener('fetch', e => { if (url.pathname.startsWith('/v1/')) return; // WS: skip if (url.protocol === 'ws:' || url.protocol === 'wss:') return; - // Shell: cache first, network fallback + // Network first, cache fallback (ensures updates are picked up immediately) e.respondWith( - caches.match(e.request).then(cached => cached || fetch(e.request).then(resp => { + fetch(e.request).then(resp => { if (resp.ok && SHELL.includes(url.pathname)) { const clone = resp.clone(); caches.open(CACHE).then(c => c.put(e.request, clone)); } return resp; - }).catch(() => { - if (e.request.mode === 'navigate') { - return caches.match('/'); - } + }).catch(() => caches.match(e.request).then(cached => { + if (cached) return cached; + if (e.request.mode === 'navigate') return caches.match('/'); })) ); }); From b9e7b3e05cfbc162ac57fb055a9d7bcbcae4cfdb Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 09:12:09 +0400 Subject: [PATCH 14/50] fix: arch linux uses rustup for wasm target support Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/scripts/build-linux.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/warzone/scripts/build-linux.sh b/warzone/scripts/build-linux.sh index d0653a9..9c3d270 100755 --- a/warzone/scripts/build-linux.sh +++ b/warzone/scripts/build-linux.sh @@ -410,7 +410,11 @@ do_local_deps() { sudo dnf install -y gcc gcc-c++ make pkg-config openssl-devel curl >/dev/null 2>&1 ;; pacman) - sudo pacman -Sy --noconfirm base-devel pkg-config openssl curl >/dev/null 2>&1 + sudo pacman -Sy --noconfirm base-devel pkg-config openssl curl rustup >/dev/null 2>&1 + # Arch: ensure rustup manages the toolchain (pacman rust conflicts with rustup) + if ! rustup show active-toolchain &>/dev/null; then + rustup default stable 2>/dev/null || true + fi ;; brew) brew install openssl pkg-config 2>/dev/null || true From 067f1ea20b9d2375a4f6dd4eb136f28974b68214 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 09:15:40 +0400 Subject: [PATCH 15/50] v0.0.25: fix text selection in web chat, don't steal focus when selecting Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +++++----- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- warzone/crates/warzone-server/src/routes/web.rs | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index d1fb3e9..820c347 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.24" +version = "0.0.25" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.24" +version = "0.0.25" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.24" +version = "0.0.25" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.24" +version = "0.0.25" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.24" +version = "0.0.25" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 1167562..3b39bbe 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.24" +version = "0.0.25" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 56cd3cf..4981782 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.24" +version = "0.0.25" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 3dbf505..82a89f0 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v6'; +const CACHE = 'wz-v7'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -241,7 +241,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.24'; +const VERSION = '0.0.25'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -1226,7 +1226,7 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB document.getElementById('btn-recover').onclick = () => doRecover(); document.getElementById('btn-enter').onclick = () => enterChat(); document.getElementById('send-btn').onclick = () => doSend(); -document.getElementById('messages').onclick = () => document.getElementById('msg-input').focus(); +document.getElementById('messages').onclick = () => { if (!window.getSelection().toString()) document.getElementById('msg-input').focus(); }; document.getElementById('hdr-eth').onclick = function() { if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address')); }; From 8603087afb726859320571fa63072cd2509b3e15 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 09:45:45 +0400 Subject: [PATCH 16/50] =?UTF-8?q?v0.0.27:=20TG-compatible=20bots=20?= =?UTF-8?q?=E2=80=94=20plaintext=20send,=20numeric=20IDs,=20webhooks,=20Bo?= =?UTF-8?q?tFather?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot compatibility: - Clients send plaintext bot_message to bot aliases (no E2E encryption) - Numeric chat_id: fp_to_numeric_id() deterministic hash, accept string/number - Webhook delivery: POST updates to bot's webhook URL (async, fire-and-forget) - getUpdates timeout raised to 50s (was 30, TG uses 50) - parse_mode HTML rendered in web client - E2E bot registration: optional seed + bundle for encrypted bot sessions BotFather + instance control: - --enable-bots CLI flag (default: disabled) - BotFather auto-created on first start (@botfather alias) - Bot ownership: owner fingerprint stored in bot_info - All bot endpoints return 403 when disabled Bot Bridge: - tools/bot-bridge.py: TG-compatible proxy for unmodified TG bots - Translates chat_id int↔string, proxies getUpdates/sendMessage - README with python-telegram-bot and Telegraf examples Test fixes: - Updated tests for ETH address display in header/messages Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +- warzone/Cargo.toml | 2 +- .../crates/warzone-client/src/tui/commands.rs | 42 ++ warzone/crates/warzone-client/src/tui/draw.rs | 7 +- .../crates/warzone-client/src/tui/types.rs | 3 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- warzone/crates/warzone-server/src/main.rs | 34 ++ .../crates/warzone-server/src/routes/bot.rs | 398 +++++++++++++----- .../crates/warzone-server/src/routes/mod.rs | 2 +- .../warzone-server/src/routes/resolve.rs | 19 + .../crates/warzone-server/src/routes/web.rs | 31 +- warzone/crates/warzone-server/src/state.rs | 17 + warzone/tools/README.md | 38 ++ warzone/tools/bot-bridge.py | 175 ++++++++ 14 files changed, 660 insertions(+), 120 deletions(-) create mode 100644 warzone/tools/README.md create mode 100755 warzone/tools/bot-bridge.py diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 820c347..637eef9 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.25" +version = "0.0.27" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.25" +version = "0.0.27" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.25" +version = "0.0.27" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.25" +version = "0.0.27" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.25" +version = "0.0.27" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 3b39bbe..c30fc26 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.25" +version = "0.0.27" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index fe7c4be..3fc5219 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -541,6 +541,48 @@ impl App { } }; + // If peer is a bot alias, send plaintext (no E2E) + let is_bot_peer = { + let url = format!("{}/v1/alias/whois/{}", client.base_url, normfp(&peer)); + match client.client.get(&url).send().await { + Ok(resp) => resp.json::().await.ok() + .and_then(|d| d.get("alias").and_then(|a| a.as_str().map(|s| s.ends_with("bot") || s.ends_with("Bot") || s.ends_with("_bot")))) + .unwrap_or(false), + Err(_) => false, + } + }; + + if is_bot_peer { + let msg_id = uuid::Uuid::new_v4().to_string(); + let bot_msg = serde_json::json!({ + "type": "bot_message", + "id": msg_id, + "from": normfp(&self.our_fp), + "from_name": if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { self.our_eth.clone() }, + "text": text, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default(); + match client.send_message(&peer, Some(&self.our_fp), &msg_bytes).await { + Ok(_) => { + self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent); + let _ = db.touch_contact(&peer, None); + let _ = db.store_message(&peer, &self.our_fp, &text, true); + self.add_message(ChatLine { + sender: if self.our_eth.is_empty() { self.our_fp[..12].to_string() } else { format!("{}...", &self.our_eth[..self.our_eth.len().min(12)]) }, + 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() }); + } + } + 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(); diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs index 94a8309..78a7445 100644 --- a/warzone/crates/warzone-client/src/tui/draw.rs +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -216,15 +216,16 @@ mod tests { // 2. header_contains_fingerprint // ---------------------------------------------------------------- #[test] - fn header_contains_fingerprint() { + fn header_contains_identity() { let app = make_app(); let mut terminal = make_terminal(); terminal.draw(|f| app.draw(f)).unwrap(); let header = row_text(&terminal, 0); + // Header shows ETH address (if seed exists) or fingerprint assert!( - header.contains("aabbcc"), - "header should contain our fingerprint 'aabbcc', got: {header}" + header.contains("aabbcc") || header.contains("0x"), + "header should contain fingerprint or ETH address, got: {header}" ); } diff --git a/warzone/crates/warzone-client/src/tui/types.rs b/warzone/crates/warzone-client/src/tui/types.rs index 5782eab..f6265fa 100644 --- a/warzone/crates/warzone-client/src/tui/types.rs +++ b/warzone/crates/warzone-client/src/tui/types.rs @@ -163,7 +163,8 @@ mod tests { let msgs = app.messages.lock().unwrap(); assert!(msgs.len() >= 2); assert!(msgs[0].is_system); - assert!(msgs[0].text.contains("aabbcc")); + // First message shows ETH address (if seed exists) or fingerprint + assert!(msgs[0].text.contains("You are")); } #[test] diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 4981782..89dca0e 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.25" +version = "0.0.27" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 418a120..55b5335 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -22,6 +22,10 @@ struct Cli { /// Federation config file (JSON). Enables server-to-server message relay. #[arg(short, long)] federation: Option, + + /// Enable bot API (disabled by default) + #[arg(long, default_value = "false")] + enable_bots: bool, } #[tokio::main] @@ -49,6 +53,36 @@ async fn main() -> anyhow::Result<()> { state.federation = Some(handle); } + // Enable bot API if requested + state.bots_enabled = cli.enable_bots; + if cli.enable_bots { + tracing::info!("Bot API enabled"); + + // Auto-create BotFather if it doesn't exist + let botfather_fp = "0000000000000000botfather00000000"; + let botfather_key = format!("bot_fp:{}", botfather_fp); + if state.db.tokens.get(botfather_key.as_bytes()).ok().flatten().is_none() { + let token = format!("botfather:{}", hex::encode(rand::random::<[u8; 16]>())); + let bot_info = serde_json::json!({ + "name": "BotFather", + "fingerprint": botfather_fp, + "token": token, + "owner": "system", + "e2e": false, + "created_at": chrono::Utc::now().timestamp(), + }); + let key = format!("bot:{}", token); + let _ = state.db.tokens.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + let _ = state.db.tokens.insert(botfather_key.as_bytes(), token.as_bytes()); + // Register alias + let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather"); + tracing::info!("BotFather created: @botfather (token: {}...)", &token[..20]); + } else { + tracing::info!("BotFather already exists"); + } + } + // Spawn federation outgoing WS connection if enabled if let Some(ref fed) = state.federation { let handle = fed.clone(); diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 969d54f..1e9f4ab 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -49,12 +49,55 @@ pub fn routes() -> Router { /// Validate a bot token against the `tokens` sled tree. /// Returns the stored bot info JSON if the token is valid. +/// Returns `None` if bots are disabled on this server instance. fn validate_bot_token(state: &AppState, token: &str) -> Option { + if !state.bots_enabled { + return None; + } let key = format!("bot:{}", token); let ivec = state.db.tokens.get(key.as_bytes()).ok()??; serde_json::from_slice(&ivec).ok() } +/// Resolve a `chat_id` that may be a string fingerprint, ETH address, or numeric ID. +fn resolve_chat_id(state: &AppState, chat_id: &serde_json::Value) -> Option { + match chat_id { + serde_json::Value::String(s) => { + let clean: String = s + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + if clean.len() >= 16 { + Some(clean) + } else if s.starts_with("0x") { + // ETH address -- resolve + state + .db + .eth_addresses + .get(s.to_lowercase().as_bytes()) + .ok()? + .map(|v| String::from_utf8_lossy(&v).to_string()) + } else { + Some(s.clone()) + } + } + serde_json::Value::Number(n) => { + let num = n.as_i64().unwrap_or(0); + for item in state.db.keys.iter().flatten() { + let key_str = String::from_utf8_lossy(&item.0).to_string(); + if !key_str.contains(':') && key_str.len() == 32 { + if crate::routes::resolve::fp_to_numeric_id(&key_str) == num { + return Some(key_str); + } + } + } + None + } + _ => None, + } +} + /// Get the next update_id for a bot and atomically increment the counter. /// /// The counter is stored in the `tokens` tree under `bot_update_id:`. @@ -92,6 +135,91 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value) } } +// --------------------------------------------------------------------------- +// Webhook delivery (public -- called from state.rs deliver_or_queue) +// --------------------------------------------------------------------------- + +/// Check if a fingerprint belongs to a bot with a webhook, and deliver the message. +/// +/// Called from `AppState::deliver_or_queue` after queueing. Returns `true` if +/// the webhook accepted the update (HTTP 2xx), meaning the queued entry can be +/// removed. +pub async fn try_bot_webhook(state: &AppState, to_fp: &str, message: &[u8]) -> bool { + // 1. Check if this fingerprint is a bot + let token_key = format!("bot_fp:{}", to_fp); + let token = match state.db.tokens.get(token_key.as_bytes()) { + Ok(Some(v)) => String::from_utf8_lossy(&v).to_string(), + _ => return false, + }; + + // 2. Load bot info and check for webhook URL + let bot_info: serde_json::Value = + match state.db.tokens.get(format!("bot:{}", token).as_bytes()) { + Ok(Some(v)) => match serde_json::from_slice(&v) { + Ok(v) => v, + Err(_) => return false, + }, + _ => return false, + }; + + let webhook_url = match bot_info.get("webhook_url").and_then(|v| v.as_str()) { + Some(url) if !url.is_empty() => url.to_string(), + _ => return false, + }; + + // 3. Build Telegram-style update from the raw message bytes + let update = if let Ok(wire) = + bincode::deserialize::(message) + { + wire_message_to_update(&wire, message) + } else if let Ok(bot_msg) = serde_json::from_slice::(message) { + bot_json_to_update(&bot_msg) + } else { + None + }; + + let mut update = match update { + Some(u) => u, + None => return false, + }; + + // Assign a real update_id so the webhook consumer can track ordering + let uid = next_update_id(state, to_fp); + update["update_id"] = serde_json::json!(uid); + + // 4. POST to webhook URL with a short timeout + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_default(); + + match client + .post(&webhook_url) + .header("Content-Type", "application/json") + .json(&update) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + tracing::info!("Webhook delivered to {} for bot {}", webhook_url, to_fp); + true + } + Ok(resp) => { + tracing::warn!( + "Webhook {} returned {} for bot {}", + webhook_url, + resp.status(), + to_fp + ); + false + } + Err(e) => { + tracing::warn!("Webhook {} failed for bot {}: {}", webhook_url, to_fp, e); + false + } + } +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -100,6 +228,14 @@ fn enqueue_bot_update(state: &AppState, bot_fp: &str, update: serde_json::Value) struct RegisterBotRequest { name: String, fingerprint: String, + #[serde(default)] + bundle: Option>, // bincode PreKeyBundle for E2E bots + #[serde(default)] + eth_address: Option, + #[serde(default)] + e2e: Option, // true = E2E bot, false/None = plaintext bot + #[serde(default)] + owner: Option, // fingerprint of the bot creator } /// Register a bot and receive a token. @@ -113,6 +249,12 @@ async fn register_bot( State(state): State, Json(req): Json, ) -> AppResult> { + // TODO: In production, only @botfather should be able to register bots. + // For v1, direct registration is allowed for development. + if !state.bots_enabled { + return Ok(Json(serde_json::json!({"ok": false, "description": "Bot API is disabled on this server. Use a server with --enable-bots"}))); + } + let fp = req .fingerprint .chars() @@ -131,6 +273,8 @@ async fn register_bot( "name": req.name, "fingerprint": fp, "token": token, + "owner": req.owner.as_deref().unwrap_or(&fp), + "e2e": req.e2e.unwrap_or(false), "created_at": chrono::Utc::now().timestamp(), }); @@ -148,11 +292,29 @@ async fn register_bot( .tokens .insert(fp_key.as_bytes(), token.as_bytes())?; + // If E2E bot, register pre-key bundle (bot can receive encrypted messages) + if req.e2e.unwrap_or(false) { + if let Some(ref bundle_bytes) = req.bundle { + let _ = state.db.keys.insert(fp.as_bytes(), bundle_bytes.as_slice()); + let device_key = format!("device:{}:bot", fp); + let _ = state.db.keys.insert(device_key.as_bytes(), bundle_bytes.as_slice()); + tracing::info!("E2E bot: registered pre-key bundle for {}", fp); + } + } + + // Store ETH address mapping if provided + if let Some(ref eth) = req.eth_address { + let eth_lower = eth.to_lowercase(); + let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes()); + let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes()); + } + tracing::info!( - "Bot registered: {} ({}) token={}...", + "Bot registered: {} ({}) token={}... e2e={}", req.name, fp, - &token[..token.len().min(20)] + &token[..token.len().min(20)], + req.e2e.unwrap_or(false), ); // Auto-register bot alias (name must end with Bot or _bot) @@ -174,6 +336,7 @@ async fn register_bot( "name": req.name, "fingerprint": fp, "alias": format!("@{}", bot_alias), + "e2e": req.e2e.unwrap_or(false), } }))) } @@ -184,15 +347,19 @@ async fn get_me( Path(token): Path, ) -> Json { match validate_bot_token(&state, &token) { - Some(info) => Json(serde_json::json!({ - "ok": true, - "result": { - "id": info["fingerprint"], - "is_bot": true, - "first_name": info["name"], - "username": info["name"], - } - })), + Some(info) => { + let fp = info["fingerprint"].as_str().unwrap_or(""); + Json(serde_json::json!({ + "ok": true, + "result": { + "id": crate::routes::resolve::fp_to_numeric_id(fp), + "id_str": fp, + "is_bot": true, + "first_name": info["name"], + "username": info["name"], + } + })) + } None => Json(serde_json::json!({ "ok": false, "description": "invalid token", @@ -273,7 +440,7 @@ async fn get_updates( // Step 4: Long-poll if empty. if updates.is_empty() && timeout > 0 { - let wait = std::cmp::min(timeout, 30); + let wait = std::cmp::min(timeout, 50); // Poll in 1-second intervals so new messages are picked up promptly. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait); loop { @@ -346,16 +513,19 @@ fn wire_message_to_update( .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, "from": { - "id": sender_fingerprint, + "id": numeric, + "id_str": sender_fingerprint, "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { - "id": sender_fingerprint, + "id": numeric, + "id_str": sender_fingerprint, "type": "private", }, "date": chrono::Utc::now().timestamp(), @@ -370,16 +540,19 @@ fn wire_message_to_update( .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); + let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, "from": { - "id": sender_fingerprint, + "id": numeric, + "id_str": sender_fingerprint, "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { - "id": sender_fingerprint, + "id": numeric, + "id_str": sender_fingerprint, "type": "private", }, "date": chrono::Utc::now().timestamp(), @@ -394,51 +567,61 @@ fn wire_message_to_update( signal_type, payload, .. - } => Some(serde_json::json!({ - "message": { - "message_id": id, - "from": { - "id": sender_fingerprint, - "is_bot": false, - "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], - }, - "chat": { - "id": sender_fingerprint, - "type": "private", - }, - "date": chrono::Utc::now().timestamp(), - "text": format!("/call_{:?}", signal_type), - "call_signal": { - "type": format!("{:?}", signal_type), - "payload": payload, - }, - } - })), + } => { + let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": numeric, + "id_str": sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": numeric, + "id_str": sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": format!("/call_{:?}", signal_type), + "call_signal": { + "type": format!("{:?}", signal_type), + "payload": payload, + }, + } + })) + } warzone_protocol::message::WireMessage::FileHeader { id, sender_fingerprint, filename, file_size, .. - } => Some(serde_json::json!({ - "message": { - "message_id": id, - "from": { - "id": sender_fingerprint, - "is_bot": false, - "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], - }, - "chat": { - "id": sender_fingerprint, - "type": "private", - }, - "date": chrono::Utc::now().timestamp(), - "document": { - "file_name": filename, - "file_size": file_size, - }, - } - })), + } => { + let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + Some(serde_json::json!({ + "message": { + "message_id": id, + "from": { + "id": numeric, + "id_str": sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": numeric, + "id_str": sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "document": { + "file_name": filename, + "file_size": file_size, + }, + } + })) + } // Skip receipts and other variants. warzone_protocol::message::WireMessage::Receipt { .. } => None, _ => None, @@ -449,32 +632,43 @@ fn wire_message_to_update( fn bot_json_to_update(bot_msg: &serde_json::Value) -> Option { let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?; match msg_type { - "bot_message" => Some(serde_json::json!({ - "message": { - "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), - "from": { - "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), - "is_bot": true, - }, - "chat": { - "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), - "type": "private", - }, - "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), - "text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), - } - })), - "callback_query" => Some(serde_json::json!({ - "callback_query": { - "id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), - "from": { - "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), - "is_bot": false, - }, - "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), - "message": bot_msg.get("message"), - } - })), + "bot_message" => { + let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); + let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp); + Some(serde_json::json!({ + "message": { + "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": numeric, + "id_str": from_fp, + "is_bot": true, + }, + "chat": { + "id": numeric, + "id_str": from_fp, + "type": "private", + }, + "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), + "text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), + } + })) + } + "callback_query" => { + let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); + let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp); + Some(serde_json::json!({ + "callback_query": { + "id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": numeric, + "id_str": from_fp, + "is_bot": false, + }, + "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), + "message": bot_msg.get("message"), + } + })) + } _ => None, } } @@ -504,7 +698,7 @@ fn collect_updates(state: &AppState, bot_fp: &str, limit: usize) -> Vec, @@ -533,12 +727,12 @@ async fn send_message( } }; - let to_fp = req - .chat_id - .chars() - .filter(|c| c.is_ascii_hexdigit()) - .collect::() - .to_lowercase(); + let to_fp = match resolve_chat_id(&state, &req.chat_id) { + Some(fp) => fp, + None => { + return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) + } + }; let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); let msg_id = uuid::Uuid::new_v4().to_string(); @@ -608,7 +802,7 @@ async fn answer_callback_query( #[derive(Deserialize)] struct EditMessageRequest { - chat_id: String, + chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID) message_id: String, text: String, #[serde(default)] @@ -626,12 +820,12 @@ async fn edit_message_text( None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), }; let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); - let to_fp = req - .chat_id - .chars() - .filter(|c| c.is_ascii_hexdigit()) - .collect::() - .to_lowercase(); + let to_fp = match resolve_chat_id(&state, &req.chat_id) { + Some(fp) => fp, + None => { + return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) + } + }; let edit_msg = serde_json::json!({ "type": "bot_edit", @@ -732,7 +926,7 @@ async fn get_webhook_info( #[derive(Deserialize)] struct SendDocumentRequest { - chat_id: String, + chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID) /// File path, URL, or file_id reference. In v1, the reference is stored /// and forwarded as-is without server-side file hosting. document: String, @@ -751,12 +945,12 @@ async fn send_document( None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})), }; let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); - let to_fp = req - .chat_id - .chars() - .filter(|c| c.is_ascii_hexdigit()) - .collect::() - .to_lowercase(); + let to_fp = match resolve_chat_id(&state, &req.chat_id) { + Some(fp) => fp, + None => { + return Json(serde_json::json!({"ok": false, "description": "chat_id not found"})) + } + }; let msg_id = uuid::Uuid::new_v4().to_string(); let doc_msg = serde_json::json!({ diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index bfc9161..56e5df8 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,6 +1,6 @@ mod aliases; pub mod auth; -mod bot; +pub mod bot; mod calls; mod devices; mod federation; diff --git a/warzone/crates/warzone-server/src/routes/resolve.rs b/warzone/crates/warzone-server/src/routes/resolve.rs index 9408511..8d3f629 100644 --- a/warzone/crates/warzone-server/src/routes/resolve.rs +++ b/warzone/crates/warzone-server/src/routes/resolve.rs @@ -7,6 +7,20 @@ use axum::{ use crate::errors::AppResult; use crate::state::AppState; +/// Convert a fingerprint hex string to a stable i64 ID (for Telegram compatibility). +/// Uses first 8 bytes of the fingerprint as a positive i64. +pub fn fp_to_numeric_id(fp: &str) -> i64 { + let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).take(16).collect(); + let bytes = hex::decode(&clean).unwrap_or_default(); + if bytes.len() >= 8 { + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes[..8]); + i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive + } else { + 0 + } +} + pub fn routes() -> Router { Router::new().route("/resolve/:address", get(resolve_address)) } @@ -27,6 +41,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), "type": "eth", }))); } @@ -40,6 +55,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(fp), "type": "eth", "federated": true, }))); @@ -61,6 +77,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), "type": "alias", }))); } @@ -70,6 +87,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), "type": "alias", "federated": true, }))); @@ -93,6 +111,7 @@ async fn resolve_address( return Ok(Json(serde_json::json!({ "address": address, "fingerprint": fp, + "numeric_id": fp_to_numeric_id(&fp), "eth_address": eth, "type": "fingerprint", }))); diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 82a89f0..89b25db 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v7'; +const CACHE = 'wz-v9'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -241,7 +241,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.25'; +const VERSION = '0.0.27'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -547,7 +547,8 @@ function connectWebSocket() { msgText += '\\n'; } } - addMsg('@' + botName, msgText, false); + const useHtml = json.parse_mode === 'HTML'; + addMsg('@' + botName, msgText, false, null, useHtml); lastDmPeer = json.from ? normFP(json.from) : ''; return; } @@ -693,7 +694,8 @@ async function handleIncomingMessage(bytes) { msgText += '\\n'; } } - addMsg('@' + botName, msgText, false); + const useHtml = json.parse_mode === 'HTML'; + addMsg('@' + botName, msgText, false, null, useHtml); lastDmPeer = json.from ? normFP(json.from) : ''; return; } @@ -801,7 +803,7 @@ function formatSize(n) { return (n/1048576).toFixed(1) + ' MB'; } -function addMsg(from, text, isSelf, messageId) { +function addMsg(from, text, isSelf, messageId, rawHtml) { const d = document.createElement('div'); d.className = 'msg'; const color = isSelf ? '#4ade80' : peerColor(from); @@ -811,7 +813,8 @@ function addMsg(from, text, isSelf, messageId) { const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent'; receiptHtml = ' ' + receiptIndicator(status) + ''; } - d.innerHTML = '' + ts() + ' ' + lock + '' + makeAddressClickable(esc(from)) + ': ' + makeAddressClickable(esc(text)) + receiptHtml; + const bodyHtml = rawHtml ? text : makeAddressClickable(esc(text)); + d.innerHTML = '' + ts() + ' ' + lock + '' + makeAddressClickable(esc(from)) + ': ' + bodyHtml + receiptHtml; // Attach click handler for .addr spans d.querySelectorAll('.addr').forEach(el => { el.addEventListener('click', () => handleAddrClick(el.dataset.addr)); @@ -1202,6 +1205,22 @@ async function doSend() { localStorage.setItem('wz-peer', $peerInput.value.trim()); + // Check if peer is a bot — send plaintext instead of E2E + let isBotPeer = false; + try { + const wr = await fetch(SERVER + '/v1/alias/whois/' + normFP(peer)); + const wd = await wr.json(); + if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot'))) isBotPeer = true; + } catch(e) {} + + if (isBotPeer) { + const msgId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(); + const botMsg = {type:'bot_message',id:msgId,from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:text,timestamp:Math.floor(Date.now()/1000)}; + await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:normFP(peer),from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(botMsg)))})}); + addMsg((myEthAddress ? myEthAddress.slice(0,12)+'...' : myFingerprint.slice(0,19)), text, true, msgId); + return; + } + try { const msgId = await sendEncrypted(peer, text); sentMsgReceipts[msgId] = { status: 'sent', el: null }; diff --git a/warzone/crates/warzone-server/src/state.rs b/warzone/crates/warzone-server/src/state.rs index 287fb70..d12ec90 100644 --- a/warzone/crates/warzone-server/src/state.rs +++ b/warzone/crates/warzone-server/src/state.rs @@ -88,6 +88,7 @@ pub struct AppState { pub dedup: DedupTracker, pub active_calls: Arc>>, pub federation: Option, + pub bots_enabled: bool, } impl AppState { @@ -99,6 +100,7 @@ impl AppState { dedup: DedupTracker::new(), active_calls: Arc::new(Mutex::new(HashMap::new())), federation: None, + bots_enabled: false, }) } @@ -188,6 +190,21 @@ impl AppState { // 3. Queue in local DB let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); let _ = self.db.messages.insert(key.as_bytes(), message); + + // 4. Try bot webhook delivery (async, does not block the caller) + { + let state = self.clone(); + let fp = to_fp.to_string(); + let queue_key = key.clone(); + let msg = message.to_vec(); + tokio::spawn(async move { + if crate::routes::bot::try_bot_webhook(&state, &fp, &msg).await { + // Webhook accepted -- remove from offline queue + let _ = state.db.messages.remove(queue_key.as_bytes()); + } + }); + } + false } diff --git a/warzone/tools/README.md b/warzone/tools/README.md new file mode 100644 index 0000000..a265bfc --- /dev/null +++ b/warzone/tools/README.md @@ -0,0 +1,38 @@ +# featherChat Bot Tools + +## bot-bridge.py + +Proxy server that makes featherChat compatible with Telegram bot libraries. + +### Quick Start + +```bash +# 1. Register a bot on featherChat +curl -X POST http://server:7700/v1/bot/register \ + -H 'Content-Type: application/json' \ + -d '{"name":"MyBot","fingerprint":"aabbccddaabbccddaabbccddaabbccdd"}' + +# 2. Start the bridge +python3 tools/bot-bridge.py --server http://server:7700 --token YOUR_TOKEN --port 8081 + +# 3. Point your TG bot at the bridge +# Python (python-telegram-bot): +# bot = Bot(token="TOKEN", base_url="http://localhost:8081/botTOKEN") +# Node (Telegraf): +# const bot = new Telegraf("TOKEN", { telegram: { apiRoot: "http://localhost:8081" } }) +``` + +### What it does + +- Translates Telegram API calls to featherChat Bot API +- Converts numeric chat_id <-> fingerprint hex strings +- Proxies getUpdates long-polling +- Passes through sendMessage, editMessageText, etc. + +### Future: E2E Mode + +When E2E bot support is complete, the bridge will: +- Hold the bot's seed/keypair +- Decrypt incoming E2E messages before forwarding to the TG bot +- Encrypt outgoing messages with the user's ratchet session +- The TG bot sees plaintext; the server sees only ciphertext diff --git a/warzone/tools/bot-bridge.py b/warzone/tools/bot-bridge.py new file mode 100755 index 0000000..9741f94 --- /dev/null +++ b/warzone/tools/bot-bridge.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +featherChat E2E Bot Bridge + +Runs a local Telegram-compatible API server that proxies to featherChat. +Your Telegram bot connects to this bridge instead of api.telegram.org. + +Usage: + python bot-bridge.py --server http://featherchat:7700 --token YOUR_BOT_TOKEN --port 8081 + +Your bot code: + # Instead of: bot = Bot(token="...", base_url="https://api.telegram.org") + # Use: bot = Bot(token="...", base_url="http://localhost:8081") + +Architecture: + [TG Bot] <--HTTP--> [Bridge :8081] <--HTTP--> [featherChat :7700] + +The bridge translates between Telegram API format and featherChat Bot API, +handling the chat_id type differences and other incompatibilities. +""" + +import argparse +import json +import sys +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse +from urllib.request import Request, urlopen +from urllib.error import URLError + +class BotBridgeHandler(BaseHTTPRequestHandler): + server_url = "" + bot_token = "" + + def log_message(self, format, *args): + print(f"[bridge] {args[0]}" if args else "") + + def do_GET(self): + self._proxy() + + def do_POST(self): + self._proxy() + + def _proxy(self): + # Extract the method from URL: /bot/methodName + path = self.path + + # Strip /bot/ prefix if present (TG libraries send this) + if path.startswith(f'/bot{self.bot_token}/'): + method = path[len(f'/bot{self.bot_token}/'):] + elif path.startswith('/bot'): + # Library might send a different token format + parts = path.split('/', 3) + method = parts[3] if len(parts) > 3 else parts[-1] + else: + method = path.lstrip('/') + + # Read request body + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length) if content_length > 0 else b'' + + # Transform request for featherChat + fc_url = f"{self.server_url}/v1/bot/{self.bot_token}/{method}" + + # Transform body if needed + if body and method == 'sendMessage': + body = self._transform_send_message(body) + + try: + req = Request(fc_url, data=body if body else None, method=self.command) + req.add_header('Content-Type', 'application/json') + + with urlopen(req, timeout=60) as resp: + response_body = resp.read() + + # Transform response + if method == 'getUpdates': + response_body = self._transform_updates(response_body) + + self.send_response(resp.status) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(response_body) + + except URLError as e: + self.send_response(502) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({ + "ok": False, + "description": f"Bridge error: {e}" + }).encode()) + + def _transform_send_message(self, body): + """Transform sendMessage: convert numeric chat_id to string if needed.""" + try: + data = json.loads(body) + # chat_id: featherChat accepts both string and number now + # No transformation needed -- pass through + return json.dumps(data).encode() + except: + return body + + def _transform_updates(self, body): + """Transform getUpdates response: ensure chat_id is integer for TG libs.""" + try: + data = json.loads(body) + if data.get('ok') and data.get('result'): + for update in data['result']: + msg = update.get('message', {}) + # Convert string IDs to numeric for TG library compatibility + if 'from' in msg and isinstance(msg['from'].get('id'), str): + fp = msg['from']['id'] + msg['from']['id_str'] = fp + msg['from']['id'] = _fp_to_numeric(fp) + if 'chat' in msg and isinstance(msg['chat'].get('id'), str): + fp = msg['chat']['id'] + msg['chat']['id_str'] = fp + msg['chat']['id'] = _fp_to_numeric(fp) + return json.dumps(data).encode() + except: + return body + + +def _fp_to_numeric(fp: str) -> int: + """Convert fingerprint hex string to positive i64 (same as server's fp_to_numeric_id).""" + clean = ''.join(c for c in fp if c in '0123456789abcdefABCDEF')[:16] + if len(clean) >= 16: + return int(clean, 16) & 0x7FFFFFFFFFFFFFFF + return 0 + + +def main(): + parser = argparse.ArgumentParser(description='featherChat E2E Bot Bridge') + parser.add_argument('--server', required=True, help='featherChat server URL (e.g., http://localhost:7700)') + parser.add_argument('--token', required=True, help='Bot token from /v1/bot/register') + parser.add_argument('--port', type=int, default=8081, help='Local port for TG-compatible API (default: 8081)') + args = parser.parse_args() + + BotBridgeHandler.server_url = args.server.rstrip('/') + BotBridgeHandler.bot_token = args.token + + # Verify bot token + try: + req = Request(f"{args.server}/v1/bot/{args.token}/getMe") + with urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + if not data.get('ok'): + print(f"ERROR: Invalid bot token") + sys.exit(1) + bot_name = data['result'].get('first_name', '?') + print(f"Bot: {bot_name}") + except Exception as e: + print(f"ERROR: Cannot reach server: {e}") + sys.exit(1) + + server = HTTPServer(('127.0.0.1', args.port), BotBridgeHandler) + print(f"Bridge running on http://127.0.0.1:{args.port}") + print(f"Proxying to {args.server}") + print(f"") + print(f"Configure your bot:") + print(f" base_url = 'http://127.0.0.1:{args.port}/bot{args.token}'") + print(f"") + print(f"Example (python-telegram-bot):") + print(f" from telegram import Bot") + print(f" bot = Bot(token='{args.token}', base_url='http://127.0.0.1:{args.port}/bot{args.token}')") + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nBridge stopped.") + + +if __name__ == '__main__': + main() From 76cac77259c0877ccb1b95c4e57f7e4ed7a8c4b6 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 09:52:12 +0400 Subject: [PATCH 17/50] v0.0.28: BotFather-only registration, per-instance bot toggle, docs update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Bot registration restricted to BotFather (requires botfather_token) - Direct POST /v1/bot/register without BotFather auth → rejected Deploy: - systemd service reads /home/warzone/server.env for EXTRA_ARGS - deploy/warzone-server.env.mequ: no bots (default) - deploy/warzone-server.env.kh3rad3ree: --enable-bots - setup.sh copies per-hostname env file Docs updated: - LLM_HELP.md: BotFather flow, plaintext bot messaging, E2E option, bridge - LLM_BOT_DEV.md: botfather_token requirement, E2E mode, bridge section - BOT_API.md: full BotFather flow, ownership, numeric IDs, webhook delivery - SERVER.md: --enable-bots flag, per-instance config, bot system section - USAGE.md: bot messaging, BotFather, bridge tool Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/bot.rs | 19 ++- .../crates/warzone-server/src/routes/web.rs | 4 +- warzone/deploy/setup.sh | 7 ++ warzone/deploy/warzone-server.env.kh3rad3ree | 2 + warzone/deploy/warzone-server.env.mequ | 2 + warzone/deploy/warzone-server.service | 3 +- warzone/docs/BOT_API.md | 115 ++++++++++++------ warzone/docs/LLM_BOT_DEV.md | 63 ++++++++-- warzone/docs/LLM_HELP.md | 44 +++++-- warzone/docs/SERVER.md | 51 ++++++++ warzone/docs/USAGE.md | 38 +++++- 14 files changed, 288 insertions(+), 74 deletions(-) create mode 100644 warzone/deploy/warzone-server.env.kh3rad3ree create mode 100644 warzone/deploy/warzone-server.env.mequ diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 637eef9..0e1ec62 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.27" +version = "0.0.28" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.27" +version = "0.0.28" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.27" +version = "0.0.28" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.27" +version = "0.0.28" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.27" +version = "0.0.28" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index c30fc26..6c47e5c 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.27" +version = "0.0.28" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 89dca0e..171de5d 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.27" +version = "0.0.28" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 1e9f4ab..b0abdf8 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -236,6 +236,8 @@ struct RegisterBotRequest { e2e: Option, // true = E2E bot, false/None = plaintext bot #[serde(default)] owner: Option, // fingerprint of the bot creator + #[serde(default)] + botfather_token: Option, } /// Register a bot and receive a token. @@ -249,12 +251,25 @@ async fn register_bot( State(state): State, Json(req): Json, ) -> AppResult> { - // TODO: In production, only @botfather should be able to register bots. - // For v1, direct registration is allowed for development. if !state.bots_enabled { return Ok(Json(serde_json::json!({"ok": false, "description": "Bot API is disabled on this server. Use a server with --enable-bots"}))); } + // Only BotFather can register bots + // Require botfather_token field matching the stored BotFather token + if let Some(ref bf_token) = req.botfather_token { + let botfather_fp = "0000000000000000botfather00000000"; + let bf_key = format!("bot_fp:{}", botfather_fp); + let stored_token = state.db.tokens.get(bf_key.as_bytes()) + .ok().flatten() + .map(|v| String::from_utf8_lossy(&v).to_string()); + if stored_token.as_deref() != Some(bf_token.as_str()) { + return Ok(Json(serde_json::json!({"ok": false, "description": "invalid BotFather token"}))); + } + } else { + return Ok(Json(serde_json::json!({"ok": false, "description": "bot registration requires BotFather authorization. Message @botfather to create a bot."}))); + } + let fp = req .fingerprint .chars() diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 89b25db..af8e3c0 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v9'; +const CACHE = 'wz-v10'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -241,7 +241,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.27'; +const VERSION = '0.0.28'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── diff --git a/warzone/deploy/setup.sh b/warzone/deploy/setup.sh index de04223..18af261 100755 --- a/warzone/deploy/setup.sh +++ b/warzone/deploy/setup.sh @@ -32,6 +32,13 @@ chmod +x /home/warzone/warzone-server /home/warzone/warzone-client cp "federation-${HOSTNAME}.json" /home/warzone/federation.json chown warzone:warzone /home/warzone/warzone-server /home/warzone/warzone-client /home/warzone/federation.json +# Copy environment file +if [ -f "warzone-server.env.${HOSTNAME}" ]; then + cp "warzone-server.env.${HOSTNAME}" /home/warzone/server.env + chown warzone:warzone /home/warzone/server.env + echo " Environment: $(cat /home/warzone/server.env | grep -v '^#' | grep .)" +fi + # Install systemd service + journald log cap echo "[4/5] Installing systemd service..." cp warzone-server.service /etc/systemd/system/ diff --git a/warzone/deploy/warzone-server.env.kh3rad3ree b/warzone/deploy/warzone-server.env.kh3rad3ree new file mode 100644 index 0000000..5085630 --- /dev/null +++ b/warzone/deploy/warzone-server.env.kh3rad3ree @@ -0,0 +1,2 @@ +# kh3rad3ree: federation + bots enabled +EXTRA_ARGS=--enable-bots diff --git a/warzone/deploy/warzone-server.env.mequ b/warzone/deploy/warzone-server.env.mequ new file mode 100644 index 0000000..c9b6af5 --- /dev/null +++ b/warzone/deploy/warzone-server.env.mequ @@ -0,0 +1,2 @@ +# mequ: federation only, no bots +EXTRA_ARGS= diff --git a/warzone/deploy/warzone-server.service b/warzone/deploy/warzone-server.service index 534e5a0..4d65ca1 100644 --- a/warzone/deploy/warzone-server.service +++ b/warzone/deploy/warzone-server.service @@ -8,7 +8,8 @@ Type=simple User=warzone Group=warzone WorkingDirectory=/home/warzone -ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json +EnvironmentFile=-/home/warzone/server.env +ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json $EXTRA_ARGS Restart=always RestartSec=3 LimitNOFILE=65536 diff --git a/warzone/docs/BOT_API.md b/warzone/docs/BOT_API.md index ea5976e..c0e03ff 100644 --- a/warzone/docs/BOT_API.md +++ b/warzone/docs/BOT_API.md @@ -4,37 +4,45 @@ featherChat exposes a **Telegram Bot API-compatible** HTTP interface, allowing developers to build bots that interact with featherChat users using familiar -patterns. Bots register with the server, receive a token, and communicate via -long-polling (webhook support is planned). +patterns. Bots are created exclusively through **@botfather**, receive a token, +and communicate via long-polling or webhooks. -Key properties of v1: +The server must be started with `--enable-bots` to activate bot functionality. +Key properties: + +- **BotFather is required** -- only `@botfather` can register bots. It is + auto-created on first server start (token printed in server logs). - Bot aliases **must** end with `Bot`, `bot`, or `_bot` (auto-enforced on registration). - Bots receive encrypted user messages as **base64 blobs** (`raw_encrypted` - field). Plaintext bot-to-bot messages are delivered with a readable `text` - field. -- Bot-sent messages are **plaintext** (not E2E encrypted) in v1. -- `chat_id` values are hex fingerprints (not numeric Telegram-style IDs). + field) unless registered as E2E bots. Plaintext bot-to-bot messages are + delivered with a readable `text` field. +- Bot-sent messages are **plaintext** (not E2E encrypted) unless the bot is + registered in E2E mode. +- `chat_id` accepts both hex fingerprints and numeric IDs (Telegram + compatibility). Numeric IDs are also returned in `from.id`. +- Each bot has an `owner` field linking to the creating user's fingerprint. --- ## Quick Start ``` -1. Register your bot: +1. Message @botfather to create a bot (or use BotFather token from server logs). + BotFather registers the bot via: POST /v1/bot/register - {"name": "WeatherBot", "fingerprint": "aabbccdd..."} + {"name": "WeatherBot", "fingerprint": "aabbccdd...", "botfather_token": ""} 2. Extract the token from the response. 3. Poll for updates: POST /v1/bot//getUpdates - {"timeout": 5} + {"timeout": 50} 4. Send a reply: POST /v1/bot//sendMessage - {"chat_id": "", "text": "Hello!"} + {"chat_id": "", "text": "Hello!"} ``` --- @@ -48,21 +56,34 @@ POST /v1/bot/register ``` Creates a new bot, stores it in the server database, and auto-registers an -alias. +alias. **Only @botfather can call this endpoint** -- a valid `botfather_token` +is required. **Request:** ```json { "name": "MyBot", - "fingerprint": "aabbccdd1122334455667788aabbccdd" + "fingerprint": "aabbccdd1122334455667788aabbccdd", + "botfather_token": "", + "owner": "" } ``` -| Field | Type | Description | -|---------------|--------|----------------------------------------------| -| `name` | string | Display name. Alias suffix auto-added if needed. | -| `fingerprint` | string | Hex-encoded public key fingerprint for the bot. | +| Field | Type | Description | +|--------------------|--------|---------------------------------------------------| +| `name` | string | Display name. Alias suffix auto-added if needed. | +| `fingerprint` | string | Hex-encoded public key fingerprint for the bot. | +| `botfather_token` | string | BotFather authorization token (required). | +| `owner` | string | Fingerprint of the user who requested creation. | + +**E2E bot registration** (optional additional fields): + +| Field | Type | Description | +|---------------|--------|--------------------------------------------------| +| `e2e` | bool | Set to `true` to register as an E2E bot. | +| `bundle` | object | Full prekey bundle (identity_key, signed_prekey, signature, one_time_prekeys). | +| `eth_address` | string | Ethereum address for the bot. | **Response:** @@ -73,7 +94,8 @@ alias. "token": "aabbccdd11223344:9f8e7d6c5b4a39281706abcdef012345", "name": "MyBot", "fingerprint": "aabbccdd1122334455667788aabbccdd", - "alias": "@mybot_bot" + "alias": "@mybot_bot", + "owner": "" } } ``` @@ -139,14 +161,17 @@ Returns queued messages for the bot and deletes them from the queue. } ``` -| Field | Type | Description | -|-----------|------|-----------------------------------------------------| -| `timeout` | u64 | Optional. Long-poll wait in seconds. **Capped at 5.** | +| Field | Type | Description | +|-----------|------|------------------------------------------------------| +| `timeout` | u64 | Optional. Long-poll wait in seconds. **Capped at 50.** | If the queue is empty and `timeout > 0`, the server waits up to `timeout` -seconds (max 5) before returning an empty result, giving new messages a chance +seconds (max 50) before returning an empty result, giving new messages a chance to arrive. +> **Note:** If a webhook is configured via `setWebhook`, updates are delivered +> live to the webhook URL via POST instead of being queued for polling. + **Response:** ```json @@ -277,13 +302,15 @@ Sends a **plaintext** message to a user or another bot. } ``` -| Field | Type | Description | -|-----------|--------|--------------------------------------------------| -| `chat_id` | string | Recipient fingerprint (hex) or Ethereum address. | -| `text` | string | Plaintext message body. | +| Field | Type | Description | +|--------------|--------|-----------------------------------------------------------| +| `chat_id` | string/int | Recipient fingerprint (hex), Ethereum address, or numeric ID. | +| `text` | string | Message body. | +| `parse_mode` | string | Optional. `"HTML"` renders basic tags (, , , ). | -Non-hex characters in `chat_id` are stripped and the value is lowercased before -routing. +`chat_id` accepts hex fingerprint strings, Ethereum addresses, or numeric +integer IDs (Telegram compatibility). Non-hex characters in string chat_ids are +stripped and the value is lowercased before routing. **Response:** @@ -323,13 +350,15 @@ WebSocket connection (`true`) or queued for later retrieval (`false`). | Feature | Telegram | featherChat | |---------|----------|-------------| -| `chat_id` type | Numeric integer | Hex fingerprint string | -| `getUpdates` timeout | Up to 50s | Capped at **5s** | -| Message content | Always plaintext | Encrypted messages arrive as `raw_encrypted` base64; bot must decrypt if it has a session | -| Bot-sent messages | Plaintext | Plaintext (not E2E encrypted) in v1 | -| Inline keyboards / callback queries | Supported | Not yet (planned) | +| `chat_id` type | Numeric integer | Hex fingerprint string or numeric integer (both accepted) | +| `getUpdates` timeout | Up to 50s | Capped at **50s** | +| Message content | Always plaintext | Encrypted messages arrive as `raw_encrypted` base64; E2E bots can decrypt | +| Bot-sent messages | Plaintext | Plaintext by default; E2E mode available | +| `from.id` | Numeric integer | Numeric integer (`from.id_str` has hex fingerprint) | +| `parse_mode` | Renders HTML/Markdown | HTML rendered (, , , ) | +| Inline keyboards / callback queries | Supported | Stored + delivered, no popup | +| Webhooks (`setWebhook`) | Supported | Implemented -- updates delivered live to webhook URL | | Media groups | Supported | Not yet (planned) | -| Webhooks (`setWebhook`) | Supported | Not yet (planned) | | File download (`getFile`) | Supported | Not yet (planned) | --- @@ -344,7 +373,7 @@ TOKEN = "your_bot_token" API = f"http://localhost:7700/v1/bot/{TOKEN}" while True: - resp = requests.post(f"{API}/getUpdates", json={"timeout": 5}).json() + resp = requests.post(f"{API}/getUpdates", json={"timeout": 50}).json() for update in resp.get("result", []): msg = update.get("message", {}) text = msg.get("text") or "[encrypted]" @@ -392,10 +421,20 @@ like a password.** --- +## Bot Bridge (`tools/bot-bridge.py`) + +A compatibility layer for existing Telegram bot libraries. Translates between +featherChat Bot API and standard TG libraries (python-telegram-bot, aiogram, +Telegraf). Handles differences like fingerprint-based chat_id, numeric ID +translation, and webhook forwarding. + +```bash +python tools/bot-bridge.py --token YOUR_BOT_TOKEN --server http://localhost:7700 +``` + +--- + ## Future Plans -- **Webhook mode** (`setWebhook`) -- push updates to a URL instead of polling. -- **Inline keyboards and callback queries** -- interactive message buttons. -- **E2E encrypted bot sessions** -- bots participate in X3DH key exchange. - **File send/receive APIs** -- `sendDocument`, `getFile`. - **Group bot support** -- bots in group chats with sender-key encryption. diff --git a/warzone/docs/LLM_BOT_DEV.md b/warzone/docs/LLM_BOT_DEV.md index b2275d5..0e81e0e 100644 --- a/warzone/docs/LLM_BOT_DEV.md +++ b/warzone/docs/LLM_BOT_DEV.md @@ -5,15 +5,42 @@ Server: `http://HOST:7700` All bot endpoints: `/v1/bot//METHOD` -Register bot: +**Prerequisites:** The server must be started with `--enable-bots` to activate bot functionality. + +### BotFather Registration + +Bots can only be created through `@botfather`. On first server start, BotFather is auto-created and its token is printed in the server logs. + +To create a bot: +1. Message `@botfather` in the chat client (or use the BotFather token from server logs for programmatic access). +2. BotFather calls `/v1/bot/register` with your request, including a `botfather_token` field for authorization. + +Registration request (sent by BotFather internally): ``` POST /v1/bot/register -{"name":"MyBot","fingerprint":"any_32_hex_chars"} -→ {"ok":true,"result":{"token":"TOKEN","alias":"@mybot_bot"}} +{"name":"MyBot","fingerprint":"any_32_hex_chars","botfather_token":""} +→ {"ok":true,"result":{"token":"TOKEN","alias":"@mybot_bot","owner":""}} ``` Bot names must end with Bot/bot/_bot. Token format: `:`. +### E2E Bot Mode + +Bots can optionally participate in E2E encryption. Pass additional fields during registration: + +```json +{ + "name": "SecureBot", + "fingerprint": "...", + "botfather_token": "...", + "e2e": true, + "bundle": { "identity_key": "...", "signed_prekey": "...", "signature": "...", "one_time_prekeys": ["..."] }, + "eth_address": "0x..." +} +``` + +An E2E bot registers a full prekey bundle and can establish X3DH sessions with users, receiving decryptable messages instead of `raw_encrypted` blobs. + ## Endpoints ### getMe @@ -190,25 +217,41 @@ poll(); | Feature | Telegram | featherChat | |---------|----------|------------| -| chat_id | numeric | hex fingerprint string | -| getUpdates timeout | up to 50s | up to 30s | -| User messages | plaintext | E2E encrypted (text=null in v1) | -| Bot messages | plaintext | plaintext (no E2E) | +| chat_id | numeric | hex fingerprint string OR numeric (both accepted) | +| getUpdates timeout | up to 50s | up to 50s | +| User messages | plaintext | E2E encrypted (text=null unless E2E bot) | +| Bot messages | plaintext | plaintext (no E2E) unless E2E bot mode | | File upload | multipart form | JSON reference (v1) | | Inline keyboards | full support | stored + delivered, no popup | | Callback queries | full popup | acknowledged, no popup | -| Webhooks | full HTTPS | URL stored, delivery planned | +| Webhooks | full HTTPS | URL stored, updates delivered live (POST to webhook URL) | | Media groups | supported | not yet | -| parse_mode | renders HTML/MD | stored, not rendered (v1) | +| parse_mode | renders HTML/MD | HTML rendered (, , , ) | ## Key Patterns **Always use offset** — without it, the same messages are returned every poll. -**chat_id is the sender's fingerprint** — use `msg.chat.id` or `msg.from.id`. +**chat_id is the sender's fingerprint** — use `msg.chat.id` or `msg.from.id`. Note: `from.id` is now a numeric integer for TG compatibility; use `from.id_str` for the hex fingerprint. **Bot alias** — users message bots via `@mybot_bot` which resolves to the bot's fingerprint. **Error handling** — all responses have `{"ok": bool}`. Check `ok` before accessing `result`. **Rate limits** — 200 concurrent server requests, no per-bot limit (be reasonable). + +## Bot Bridge (`tools/bot-bridge.py`) + +The bot bridge provides a compatibility layer for existing Telegram bot libraries. It translates between featherChat's Bot API and standard TG libraries. + +**Supported libraries:** +- **python-telegram-bot** — set `base_url` to `http://your-server:7700/v1/bot/` +- **aiogram** — configure the bot session with the featherChat server URL +- **Telegraf (Node.js)** — set `telegram.apiRoot` to `http://your-server:7700/v1/bot` + +Usage: +```bash +python tools/bot-bridge.py --token YOUR_BOT_TOKEN --server http://localhost:7700 +``` + +The bridge handles differences like fingerprint-based chat_id, numeric ID translation, and webhook forwarding. diff --git a/warzone/docs/LLM_HELP.md b/warzone/docs/LLM_HELP.md index cbc415d..0b0e408 100644 --- a/warzone/docs/LLM_HELP.md +++ b/warzone/docs/LLM_HELP.md @@ -131,21 +131,47 @@ no prekeys available | recipient's one-time prekeys exhausted | they need to re- ## Bot API (Telegram-compatible) -Register: POST /v1/bot/register {"name":"MyBot","fingerprint":""} -→ returns token + auto-creates @mybot_bot alias +### BotFather + +`@botfather` is the only way to create bots. It is auto-created on first server start (token printed in server logs). Users message `@botfather` to register new bots. The server must be started with `--enable-bots` to activate bot functionality. + +### Registration + +Bots are created via BotFather, which calls POST /v1/bot/register with a `botfather_token` field. Each bot has an `owner` field linking it to the user who requested creation. Bot aliases must end with Bot, bot, or _bot. Non-bots cannot use these. +### Plaintext Bot Messaging + +Clients auto-detect bot aliases (names ending in Bot/bot/_bot) and send messages unencrypted (plaintext JSON). No E2E session is established for standard bot interactions. + +### E2E Bot Option + +Bots can optionally participate in E2E encryption by registering with a seed and prekey bundle. Pass `e2e: true` + `bundle` + `eth_address` in the registration request. Users messaging an E2E bot establish a normal X3DH session. + +### Bot Bridge + +`tools/bot-bridge.py` provides Telegram library compatibility. It translates between featherChat Bot API and standard TG bot libraries (python-telegram-bot, aiogram, Telegraf). + +### Endpoints + |Endpoint|Method|Body| |---|---|---| -|/bot/:token/getMe|GET|—| -|/bot/:token/getUpdates|POST|{"timeout":5}| -|/bot/:token/sendMessage|POST|{"chat_id":"","text":"Hello"}| +|/bot/:token/getMe|GET|--| +|/bot/:token/getUpdates|POST|{"timeout":50}| +|/bot/:token/sendMessage|POST|{"chat_id":"","text":"Hello","parse_mode":"HTML"}| +|/bot/:token/setWebhook|POST|{"url":"https://..."}| +|/bot/:token/deleteWebhook|POST|--| +|/bot/:token/getWebhookInfo|GET|--| - Token format: fp_prefix:random_hex -- getUpdates: long-poll (max 5s), returns then deletes queued msgs -- sendMessage: plaintext JSON, NOT E2E encrypted +- getUpdates: long-poll (max 50s), returns then deletes queued msgs +- sendMessage: plaintext JSON, NOT E2E encrypted (unless E2E bot) - Bot msgs delivered via same routing (WS push or DB queue) +- Webhooks: updates are delivered live to the registered URL (POST with JSON body) +- chat_id: accepts hex fingerprint or numeric ID (TG compatibility) +- parse_mode: `HTML` renders basic HTML tags (, , , ) in clients +- from.id is numeric (integer), from.id_str contains the hex fingerprint Update types in getUpdates: - Encrypted msg: text=null, raw_encrypted=base64 @@ -153,15 +179,13 @@ Update types in getUpdates: - Call signal: text="/call_Offer", call_signal={type,payload} - File: document={file_name,file_size} -v1 limits: sendMessage is plaintext (no E2E), timeout max 5s, no webhooks yet. - Echo bot (Python): ```python import requests, time TOKEN = "your_token" API = f"http://srv:7700/v1/bot/{TOKEN}" while True: - for u in requests.post(f"{API}/getUpdates",json={"timeout":5}).json().get("result",[]): + for u in requests.post(f"{API}/getUpdates",json={"timeout":50}).json().get("result",[]): m = u["message"] if m.get("text"): requests.post(f"{API}/sendMessage",json={"chat_id":m["chat"]["id"],"text":"Echo: "+m["text"]}) time.sleep(1) diff --git a/warzone/docs/SERVER.md b/warzone/docs/SERVER.md index 6d0d712..5371dcc 100644 --- a/warzone/docs/SERVER.md +++ b/warzone/docs/SERVER.md @@ -67,6 +67,7 @@ Rust 1.75 or later (`rust-version = "1.75"` in `Cargo.toml`). | `--bind` | `-b` | `0.0.0.0:7700` | Address and port to listen on | | `--data-dir` | `-d` | `./warzone-data` | Directory for the sled database | | `--federation` | `-f` | *(none)* | Path to federation JSON config file | +| `--enable-bots` | | *(off)* | Enable Bot API and auto-create BotFather on startup | ### Environment Variables @@ -75,6 +76,20 @@ Rust 1.75 or later (`rust-version = "1.75"` in `Cargo.toml`). | `RUST_LOG` | `warn` (production) | Log filter. Examples: `info`, `warzone_server=debug`, `trace` | | `WZP_RELAY_ADDR` | *(none)* | WZP voice relay address advertised to clients | +### Per-Instance Configuration (`server.env`) + +Each server instance can use a `server.env` file for per-instance settings. +Place it in the working directory or alongside the binary. This allows +different instances to have different configurations (e.g., bots enabled on +one server but not another). + +Example `server.env`: +``` +RUST_LOG=info +WZP_RELAY_ADDR=relay.example.com:3478 +ENABLE_BOTS=true +``` + ### systemd Service A production-ready unit file is provided at `deploy/warzone-server.service`: @@ -209,6 +224,42 @@ Returns JSON with connection state, peer info, and presence data. --- +## 4b. Bot System + +### Enabling Bots + +Start the server with `--enable-bots` to activate bot functionality. Without +this flag, all bot endpoints return 403. + +```bash +./warzone-server --bind 0.0.0.0:7700 --enable-bots +``` + +### BotFather Auto-Creation + +On first start with `--enable-bots`, the server auto-creates the `@botfather` +bot. The BotFather token is printed to the server logs. Users interact with +`@botfather` to register new bots. + +### Per-Instance Bot Toggle + +Bot support can be enabled independently per server instance: + +| Instance | Bots | Config | +|----------|------|--------| +| mequ | Disabled | No `--enable-bots` flag | +| kh3rad3ree | Enabled | `--enable-bots` flag set | + +### Bot Webhook Delivery + +When a bot has a webhook configured (via `setWebhook`), incoming messages are +delivered live to the webhook URL via HTTP POST instead of being queued for +`getUpdates` polling. This is integrated into the standard message routing +pipeline -- `deliver_or_queue` checks for webhook configuration before +queueing. + +--- + ## 5. API Reference All endpoints are prefixed with `/v1`. The web UI is served at `/`. diff --git a/warzone/docs/USAGE.md b/warzone/docs/USAGE.md index c872424..1555412 100644 --- a/warzone/docs/USAGE.md +++ b/warzone/docs/USAGE.md @@ -55,10 +55,11 @@ The `scripts/build-linux.sh` script builds Linux x86_64 binaries on a Hetzner Cl warzone-server --bind 0.0.0.0:7700 --data-dir ./warzone-data ``` -| Flag | Default | Description | -|--------------|------------------|--------------------| -| `--bind` | `0.0.0.0:7700` | Listen address | -| `--data-dir` | `./warzone-data` | sled database path | +| Flag | Default | Description | +|-----------------|------------------|--------------------------------------| +| `--bind` | `0.0.0.0:7700` | Listen address | +| `--data-dir` | `./warzone-data` | sled database path | +| `--enable-bots` | *(off)* | Enable Bot API and BotFather | Environment variables: @@ -336,6 +337,35 @@ The friend list is encrypted client-side and stored on the server as an opaque b --- +## Bots + +### Messaging Bots + +Clients auto-detect bot aliases (names ending in `Bot`, `bot`, or `_bot`) and send messages as plaintext -- no E2E session is established. Simply use `/peer @mybot_bot` and type your message. The client handles the rest. + +### Creating Bots with BotFather + +To create a bot, message `@botfather`: + +1. `/peer @botfather` +2. Send a message requesting a new bot (e.g., "create WeatherBot") +3. BotFather registers the bot and returns the API token +4. Use the token to run your bot against the server's Bot API + +BotFather is auto-created on servers that have `--enable-bots` enabled. It is the only way to create bots. + +### Bot Bridge for Telegram Compatibility + +The `tools/bot-bridge.py` script provides a compatibility layer that lets you use existing Telegram bot libraries (python-telegram-bot, aiogram, Telegraf) with featherChat. It translates between the two APIs, handling differences like fingerprint-based addressing and numeric ID translation. + +```bash +python tools/bot-bridge.py --token YOUR_BOT_TOKEN --server http://localhost:7700 +``` + +See `docs/BOT_API.md` and `docs/LLM_BOT_DEV.md` for full Bot API documentation. + +--- + ## Federation Federation connects two featherChat servers so that users on different servers can message each other transparently. From 6196057f3e5a209904920be71c749101217cdb8b Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 10:04:31 +0400 Subject: [PATCH 18/50] =?UTF-8?q?feat:=20build-bleeding.sh=20=E2=80=94=20A?= =?UTF-8?q?rch=20Linux=20Docker=20on=20Fedora=20VM=20for=20bleeding=20edge?= =?UTF-8?q?=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VM: fc-bleeding (Fedora 41), Build: archlinux:latest Docker container Output: target/linux-x86_64-bleeding/ No conflict with fc-builder (build-linux.sh) Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/scripts/build-bleeding.sh | 283 ++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100755 warzone/scripts/build-bleeding.sh diff --git a/warzone/scripts/build-bleeding.sh b/warzone/scripts/build-bleeding.sh new file mode 100755 index 0000000..44ca262 --- /dev/null +++ b/warzone/scripts/build-bleeding.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build featherChat Linux x86_64 bleeding-edge binaries on Hetzner Cloud. +# Uses latest Fedora VM + Arch Linux Docker container for the actual build. +# +# Usage: +# ./scripts/build-bleeding.sh --all Create VM + build + download +# ./scripts/build-bleeding.sh --ship Build + deploy to all servers + destroy +# ./scripts/build-bleeding.sh --prepare Create VM only +# ./scripts/build-bleeding.sh --build Build in Arch Docker container +# ./scripts/build-bleeding.sh --transfer Download binaries +# ./scripts/build-bleeding.sh --destroy Delete VM + +VM_NAME="fc-bleeding" +SSH_KEY_NAME="wz" +SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp" +SERVER_TYPE="cx33" +IMAGE="fedora-41" +REMOTE_USER="root" +OUTPUT_DIR="target/linux-x86_64-bleeding" +PROJECT_DIR="/Users/manwe/CascadeProjects/featherChat/warzone" + +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10" +BINS="warzone-server warzone-client" + +# Production servers (shared with build-linux.sh) +PROD_SERVERS=("root@mequ" "root@kh3rad3ree") +PROD_SERVICE="warzone-server" +PROD_BIN_DIR="/home/warzone" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +get_vm_ip() { + local ip + ip=$(hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' ') + if [ -z "$ip" ]; then + echo "ERROR: No VM '$VM_NAME' found. Run --prepare first." >&2 + exit 1 + fi + echo "$ip" +} + +ssh_cmd() { + local ip + ip=$(get_vm_ip) + ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@" +} + +# --------------------------------------------------------------------------- +# --prepare: Create Fedora VM, install Docker +# --------------------------------------------------------------------------- + +do_prepare() { + local existing + existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true) + if [ -n "$existing" ]; then + echo "VM already exists: $existing" + echo "Reusing it. Uploading fresh source..." + do_upload + return + fi + + echo "[1/5] Creating Hetzner VM: $VM_NAME ($SERVER_TYPE, $IMAGE)..." + hcloud server create \ + --name "$VM_NAME" \ + --type "$SERVER_TYPE" \ + --image "$IMAGE" \ + --ssh-key "$SSH_KEY_NAME" \ + --location fsn1 \ + --quiet + + local ip + ip=$(get_vm_ip) + echo " VM: $VM_NAME @ $ip" + + echo "[2/5] Waiting for SSH..." + for i in $(seq 1 30); do + if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then + break + fi + sleep 2 + done + + echo "[3/5] Installing Docker on Fedora..." + ssh_cmd "dnf install -y -q docker > /dev/null 2>&1 && systemctl start docker && systemctl enable docker" 2>/dev/null + + echo "[4/5] Pulling Arch Linux Docker image..." + ssh_cmd "docker pull archlinux:latest" 2>/dev/null + + echo "[5/5] Uploading source..." + do_upload + + echo "" + echo "=== VM Ready ===" + echo "IP: $ip" + echo "Next: ./scripts/build-bleeding.sh --build" +} + +do_upload() { + echo " Creating source tarball..." + tar czf /tmp/fc-bleeding-src.tar.gz \ + --exclude='target' \ + --exclude='.git' \ + --exclude='.claude' \ + --exclude='warzone-phone' \ + --exclude='notes' \ + -C "$PROJECT_DIR" . 2>/dev/null + + local ip + ip=$(get_vm_ip) + local size + size=$(du -h /tmp/fc-bleeding-src.tar.gz | cut -f1) + echo " Uploading $size to VM..." + scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-bleeding-src.tar.gz "$REMOTE_USER@$ip:/root/fc-bleeding-src.tar.gz" 2>/dev/null + ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-bleeding-src.tar.gz -C /root/featherChat" 2>/dev/null + rm -f /tmp/fc-bleeding-src.tar.gz + echo " Source uploaded." +} + +# --------------------------------------------------------------------------- +# --build: Build inside Arch Linux Docker container +# --------------------------------------------------------------------------- + +do_build() { + local ip + ip=$(get_vm_ip) + echo "=== Bleeding Edge Build on $ip ===" + echo " Host: Fedora ($IMAGE)" + echo " Build: Arch Linux (Docker, latest)" + echo "" + + echo "[1/3] Building in Arch Linux container..." + ssh_cmd "docker run --rm -v /root/featherChat:/build -w /build archlinux:latest bash -c ' + # Install deps + pacman -Sy --noconfirm base-devel pkg-config openssl rustup wasm-pack > /dev/null 2>&1 + rustup default stable 2>/dev/null + rustup target add wasm32-unknown-unknown 2>/dev/null + + # Build WASM + echo \"[wasm] Building...\" + wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg 2>&1 | tail -3 + + # Build release binaries + echo \"[cargo] Building release...\" + cargo build --release --bin warzone-server --bin warzone-client 2>&1 + '" + + echo "" + echo "[2/3] Verifying binaries..." + ssh_cmd "ls -lh /root/featherChat/target/release/warzone-server /root/featherChat/target/release/warzone-client" + + echo "" + echo "[3/3] Checking linked libraries..." + ssh_cmd "docker run --rm -v /root/featherChat:/build archlinux:latest ldd /build/target/release/warzone-server | head -10" + + echo "" + echo "=== Build Complete ===" + echo "Next: ./scripts/build-bleeding.sh --transfer" +} + +# --------------------------------------------------------------------------- +# --transfer: Download binaries +# --------------------------------------------------------------------------- + +do_transfer() { + local ip + ip=$(get_vm_ip) + echo "=== Downloading bleeding-edge binaries from $ip ===" + + mkdir -p "$OUTPUT_DIR" + + for bin in $BINS; do + echo " $bin..." + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:/root/featherChat/target/release/$bin" "$OUTPUT_DIR/$bin" 2>/dev/null + done + + # Copy federation example + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:/root/featherChat/federation.example.json" "$OUTPUT_DIR/" 2>/dev/null || true + + echo "" + echo "=== Transfer Complete ===" + ls -lh "$OUTPUT_DIR"/warzone-* + echo "" + echo "Built with: Arch Linux latest (bleeding edge)" + echo "Deploy: scp $OUTPUT_DIR/warzone-server $OUTPUT_DIR/warzone-client user@server:~/warzone/" +} + +# --------------------------------------------------------------------------- +# --destroy +# --------------------------------------------------------------------------- + +do_destroy() { + local existing + existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true) + if [ -z "$existing" ]; then + echo "No VM '$VM_NAME' to destroy." + return + fi + echo "Deleting VM: $VM_NAME" + hcloud server delete "$VM_NAME" + echo "Done." +} + +# --------------------------------------------------------------------------- +# --update-all / --ship +# --------------------------------------------------------------------------- + +do_update() { + local host="$1" + echo "=== Updating $host (bleeding) ===" + ssh "$host" "systemctl stop $PROD_SERVICE 2>/dev/null || true" + scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$host:$PROD_BIN_DIR/" + ssh "$host" "chmod +x $PROD_BIN_DIR/warzone-server $PROD_BIN_DIR/warzone-client && systemctl start $PROD_SERVICE" + sleep 1 + local status + status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || true) + echo " $host: $status" +} + +do_update_all() { + for host in "${PROD_SERVERS[@]}"; do + do_update "$host" + done +} + +do_ship() { + echo "========================================" + echo " SHIPPING (bleeding edge) to production" + echo "========================================" + echo "" + do_prepare + echo "" + do_build + echo "" + do_transfer + echo "" + do_update_all + echo "" + do_destroy + echo "" + echo "========================================" + echo " BLEEDING EDGE SHIP COMPLETE" + echo "========================================" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +case "${1:-}" in + --prepare) do_prepare ;; + --build) do_build ;; + --transfer) do_transfer ;; + --destroy) do_destroy ;; + --upload) do_upload ;; + --all) + do_prepare + do_build + do_transfer + echo "VM still running. Destroy: ./scripts/build-bleeding.sh --destroy" + ;; + --ship) do_ship ;; + --update-all) do_update_all ;; + *) + echo "Usage: $0 " + echo "" + echo " --all Create Fedora VM + build in Arch Docker + download" + echo " --ship Build + deploy to all servers + destroy VM" + echo " --prepare Create VM, install Docker, upload source" + echo " --build Build in Arch Linux Docker container" + echo " --transfer Download binaries to $OUTPUT_DIR" + echo " --destroy Delete the VM" + echo " --upload Re-upload source to existing VM" + echo " --update-all Deploy bleeding binaries to all servers" + echo "" + echo "Output: $OUTPUT_DIR/warzone-{server,client}" + echo "Host VM: Fedora latest | Build: Arch Linux latest (Docker)" + exit 1 + ;; +esac From 9dd7341809fa743bbfdfd96c3a78b866b035bf95 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 10:06:10 +0400 Subject: [PATCH 19/50] fix: build-bleeding uses fedora-43 Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/scripts/build-bleeding.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/warzone/scripts/build-bleeding.sh b/warzone/scripts/build-bleeding.sh index 44ca262..cff349f 100755 --- a/warzone/scripts/build-bleeding.sh +++ b/warzone/scripts/build-bleeding.sh @@ -16,7 +16,7 @@ VM_NAME="fc-bleeding" SSH_KEY_NAME="wz" SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp" SERVER_TYPE="cx33" -IMAGE="fedora-41" +IMAGE="fedora-43" REMOTE_USER="root" OUTPUT_DIR="target/linux-x86_64-bleeding" PROJECT_DIR="/Users/manwe/CascadeProjects/featherChat/warzone" From 362e7a765bb0957f7d83828f363e76cb616b1960 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 11:08:35 +0400 Subject: [PATCH 20/50] =?UTF-8?q?v0.0.29:=20BotFather=20=E2=80=94=20create?= =?UTF-8?q?=20bots=20by=20messaging=20@botfather?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built-in BotFather (Rust, server-side): - Intercepts messages to @botfather in deliver_or_queue - Commands: /newbot , /mybots, /deletebot , /token - Creates bot with fingerprint, token, alias, tracks ownership - Replies via push_to_client or queue (works offline) - Only active when --enable-bots is set Standalone BotFather (Python): - tools/botfather.py: uses bot API (getUpdates/sendMessage) - Delegates core ops to built-in handler - Extensible for additional features - Reads token from BOTFATHER_TOKEN env or .botfather_token file Flow: User messages @botfather → "/newbot MyBot" → gets token back Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/botfather.rs | 280 ++++++++++++++++++ warzone/crates/warzone-server/src/lib.rs | 1 + warzone/crates/warzone-server/src/main.rs | 1 + .../crates/warzone-server/src/routes/web.rs | 4 +- warzone/crates/warzone-server/src/state.rs | 13 + warzone/tools/botfather.py | 195 ++++++++++++ 9 files changed, 499 insertions(+), 9 deletions(-) create mode 100644 warzone/crates/warzone-server/src/botfather.rs create mode 100755 warzone/tools/botfather.py diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 0e1ec62..2e4edba 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.28" +version = "0.0.29" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.28" +version = "0.0.29" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.28" +version = "0.0.29" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.28" +version = "0.0.29" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.28" +version = "0.0.29" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 6c47e5c..8c962bf 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.28" +version = "0.0.29" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 171de5d..fdea9bb 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.28" +version = "0.0.29" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/botfather.rs b/warzone/crates/warzone-server/src/botfather.rs new file mode 100644 index 0000000..9d7ee9d --- /dev/null +++ b/warzone/crates/warzone-server/src/botfather.rs @@ -0,0 +1,280 @@ +//! Built-in BotFather: processes messages to @botfather and manages bot lifecycle. +//! +//! Supports: /start, /newbot, /mybots, /deletebot, /help +//! Runs as a server-side handler — no external process needed. + +use crate::state::AppState; + +const BOTFATHER_FP: &str = "0000000000000000botfather00000000"; + +/// Check if a message is destined for BotFather and handle it. +/// Called from deliver_or_queue when the recipient is the BotFather fingerprint. +/// Returns true if handled (message consumed). +pub async fn handle_botfather_message(state: &AppState, from_fp: &str, message: &[u8]) -> bool { + if !state.bots_enabled { + return false; + } + + // Try to parse as plaintext bot_message JSON + let bot_msg: serde_json::Value = match serde_json::from_slice(message) { + Ok(v) => v, + Err(_) => return false, // Encrypted messages can't be processed by built-in handler + }; + + if bot_msg.get("type").and_then(|v| v.as_str()) != Some("bot_message") { + return false; + } + + let text = bot_msg + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let from_name = bot_msg + .get("from_name") + .and_then(|v| v.as_str()) + .unwrap_or(from_fp); + + tracing::info!( + "BotFather: message from {} ({}): {}", + from_fp, + from_name, + text + ); + + let response = match text { + "/start" | "/help" => { + "Welcome to BotFather! I can help you create and manage bots.\n\n\ + Commands:\n\ + /newbot - Create a new bot\n\ + /mybots - List your bots\n\ + /deletebot - Delete a bot\n\ + /token - Get bot token\n\ + /help - Show this message" + .to_string() + } + t if t.starts_with("/newbot") => handle_newbot(state, from_fp, t).await, + t if t.starts_with("/deletebot") => handle_deletebot(state, from_fp, t).await, + "/mybots" => handle_mybots(state, from_fp).await, + t if t.starts_with("/token") => handle_token(state, from_fp, t).await, + _ => "I don't understand that command. Try /help".to_string(), + }; + + // Send response back to the user + send_botfather_reply(state, from_fp, &response).await; + true +} + +async fn handle_newbot(state: &AppState, owner_fp: &str, text: &str) -> String { + // Parse: /newbot + let name = text.strip_prefix("/newbot").unwrap_or("").trim(); + if name.is_empty() { + return "Usage: /newbot \n\nExample: /newbot WeatherBot\n\n\ + The name must end with 'bot' or 'Bot'." + .to_string(); + } + + // Validate name + if name.len() > 32 || name.len() < 3 { + return "Bot name must be 3-32 characters.".to_string(); + } + + let name_lower = name.to_lowercase(); + if !name_lower.ends_with("bot") { + return "Bot name must end with 'bot' or 'Bot'. Example: WeatherBot, my_bot".to_string(); + } + + // Check if alias is taken + let alias_key = format!("a:{}", name_lower); + if state + .db + .aliases + .get(alias_key.as_bytes()) + .ok() + .flatten() + .is_some() + { + return format!( + "Sorry, @{} is already taken. Try a different name.", + name_lower + ); + } + + // Generate fingerprint and token + let fp_bytes: [u8; 16] = rand::random(); + let fp = hex::encode(fp_bytes); + let token_rand: [u8; 16] = rand::random(); + let token = format!("{}:{}", &fp[..16], hex::encode(token_rand)); + + // Store bot info + let bot_info = serde_json::json!({ + "name": name, + "fingerprint": fp, + "token": token, + "owner": owner_fp, + "e2e": false, + "created_at": chrono::Utc::now().timestamp(), + }); + + let bot_key = format!("bot:{}", token); + let _ = state.db.tokens.insert( + bot_key.as_bytes(), + serde_json::to_vec(&bot_info).unwrap_or_default(), + ); + let fp_key = format!("bot_fp:{}", fp); + let _ = state.db.tokens.insert(fp_key.as_bytes(), token.as_bytes()); + + // Register alias + let _ = state + .db + .aliases + .insert(alias_key.as_bytes(), fp.as_bytes()); + let _ = state + .db + .aliases + .insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes()); + + tracing::info!( + "BotFather: created bot @{} for owner {}", + name_lower, + owner_fp + ); + + format!( + "Done! Your new bot @{} is ready.\n\n\ + Token: {}\n\n\ + Keep this token secret! Use it to access the Bot API.\n\n\ + API endpoint: /v1/bot/{}/getUpdates", + name_lower, token, token + ) +} + +async fn handle_deletebot(state: &AppState, owner_fp: &str, text: &str) -> String { + let name = text + .strip_prefix("/deletebot") + .unwrap_or("") + .trim() + .to_lowercase(); + if name.is_empty() { + return "Usage: /deletebot ".to_string(); + } + + // Find the bot + let alias_key = format!("a:{}", name); + let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found.", name), + }; + + // Get bot info to verify ownership + let token_key = format!("bot_fp:{}", bot_fp); + let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found in registry.", name), + }; + + let bot_key = format!("bot:{}", token); + if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() { + if let Ok(info) = serde_json::from_slice::(&info_bytes) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner != owner_fp && owner != "system" { + return format!("You don't own @{}. Only the owner can delete it.", name); + } + } + } + + // Delete everything + let _ = state.db.tokens.remove(bot_key.as_bytes()); + let _ = state.db.tokens.remove(token_key.as_bytes()); + let _ = state.db.aliases.remove(alias_key.as_bytes()); + let _ = state + .db + .aliases + .remove(format!("fp:{}", bot_fp).as_bytes()); + let _ = state.db.keys.remove(bot_fp.as_bytes()); + + tracing::info!("BotFather: deleted bot @{} by owner {}", name, owner_fp); + format!("Bot @{} has been deleted.", name) +} + +async fn handle_mybots(state: &AppState, owner_fp: &str) -> String { + let mut bots = Vec::new(); + + for item in state.db.tokens.iter().flatten() { + let key = String::from_utf8_lossy(&item.0).to_string(); + if !key.starts_with("bot:") { + continue; + } + if let Ok(info) = serde_json::from_slice::(&item.1) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner == owner_fp { + let name = info.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let e2e = info.get("e2e").and_then(|v| v.as_bool()).unwrap_or(false); + let mode = if e2e { "E2E" } else { "plaintext" }; + bots.push(format!(" @{} ({})", name.to_lowercase(), mode)); + } + } + } + + if bots.is_empty() { + "You have no bots. Use /newbot to create one.".to_string() + } else { + format!("Your bots ({}):\n{}", bots.len(), bots.join("\n")) + } +} + +async fn handle_token(state: &AppState, owner_fp: &str, text: &str) -> String { + let name = text + .strip_prefix("/token") + .unwrap_or("") + .trim() + .to_lowercase(); + if name.is_empty() { + return "Usage: /token ".to_string(); + } + + let alias_key = format!("a:{}", name); + let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Bot @{} not found.", name), + }; + + let token_key = format!("bot_fp:{}", bot_fp); + let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() { + Some(v) => String::from_utf8_lossy(&v).to_string(), + None => return format!("Token not found for @{}.", name), + }; + + // Verify ownership + let bot_key = format!("bot:{}", token); + if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() { + if let Ok(info) = serde_json::from_slice::(&info_bytes) { + let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or(""); + if owner != owner_fp { + return format!("You don't own @{}.", name); + } + } + } + + format!("Token for @{}:\n{}", name, token) +} + +/// Send a reply from BotFather to a user. +async fn send_botfather_reply(state: &AppState, to_fp: &str, text: &str) { + let msg = serde_json::json!({ + "type": "bot_message", + "id": uuid::Uuid::new_v4().to_string(), + "from": BOTFATHER_FP, + "from_name": "BotFather", + "text": text, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&msg).unwrap_or_default(); + + // Deliver directly (don't go through deliver_or_queue to avoid recursion) + if !state.push_to_client(to_fp, &msg_bytes).await { + // Queue for offline pickup + let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4()); + let _ = state.db.messages.insert(key.as_bytes(), msg_bytes); + } +} diff --git a/warzone/crates/warzone-server/src/lib.rs b/warzone/crates/warzone-server/src/lib.rs index e852ef1..9da2ec7 100644 --- a/warzone/crates/warzone-server/src/lib.rs +++ b/warzone/crates/warzone-server/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth_middleware; +pub mod botfather; pub mod config; pub mod db; pub mod errors; diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 55b5335..55c07cf 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -1,5 +1,6 @@ use clap::Parser; +mod botfather; pub mod auth_middleware; mod config; mod db; diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index af8e3c0..5d6276b 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v10'; +const CACHE = 'wz-v11'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -241,7 +241,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.28'; +const VERSION = '0.0.29'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── diff --git a/warzone/crates/warzone-server/src/state.rs b/warzone/crates/warzone-server/src/state.rs index d12ec90..d5ff857 100644 --- a/warzone/crates/warzone-server/src/state.rs +++ b/warzone/crates/warzone-server/src/state.rs @@ -173,6 +173,19 @@ impl AppState { /// 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 { + // BotFather: intercept messages to @botfather + if self.bots_enabled && to_fp == "0000000000000000botfather00000000" { + // Extract sender from message + if let Ok(msg) = serde_json::from_slice::(message) { + let from = msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); + if !from.is_empty() { + if crate::botfather::handle_botfather_message(self, from, message).await { + return true; + } + } + } + } + // 1. Try local WebSocket push if self.push_to_client(to_fp, message).await { return true; diff --git a/warzone/tools/botfather.py b/warzone/tools/botfather.py new file mode 100755 index 0000000..4de1c17 --- /dev/null +++ b/warzone/tools/botfather.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +featherChat BotFather (Standalone) + +A Telegram-style BotFather that manages bot creation via chat. +Uses the featherChat Bot API — runs as a regular bot process. + +Usage: + python botfather.py --server http://localhost:7700 + +On first run, it registers itself as @botfather if not already registered. +Subsequent runs reuse the stored token from .botfather_token file. + +Commands: + /start, /help - Show help + /newbot - Create a new bot (name must end with bot/Bot) + /mybots - List your bots + /deletebot - Delete a bot you own + /token - Show token for your bot +""" + +import argparse +import json +import os +import sys +import time +from urllib.request import Request, urlopen +from urllib.error import URLError + +TOKEN_FILE = ".botfather_token" + + +def api(server, token, method, data=None): + """Call a bot API method.""" + url = f"{server}/v1/bot/{token}/{method}" + body = json.dumps(data).encode() if data else None + req = Request(url, data=body, method="POST" if body else "GET") + req.add_header("Content-Type", "application/json") + try: + with urlopen(req, timeout=60) as resp: + return json.loads(resp.read()) + except URLError as e: + print(f"API error ({method}): {e}") + return {"ok": False} + + +def send(server, token, chat_id, text): + """Send a message.""" + return api(server, token, "sendMessage", {"chat_id": chat_id, "text": text}) + + +def register_botfather(server): + """Register BotFather with the server. Returns token.""" + # BotFather registers itself — it needs the built-in BotFather token + # to authorize. Read it from the server's initial log or pass via env. + builtin_token = os.environ.get("BOTFATHER_TOKEN", "") + if not builtin_token: + print("ERROR: Set BOTFATHER_TOKEN env var to the token from server logs") + print(" (printed on first --enable-bots start)") + sys.exit(1) + + # Use the built-in token directly + return builtin_token + + +def handle_message(server, token, msg): + """Process a message and respond.""" + text = (msg.get("text") or "").strip() + chat_id = msg.get("chat", {}).get("id", "") + from_id = msg.get("from", {}).get("id_str") or str(msg.get("from", {}).get("id", "")) + + if not text or not chat_id: + return + + print(f"[{from_id[:16]}] {text}") + + if text in ("/start", "/help"): + send(server, token, chat_id, + "Welcome to BotFather! I manage bots on featherChat.\n\n" + "Commands:\n" + "/newbot - Create a bot (name must end with bot/Bot)\n" + "/mybots - List your bots\n" + "/deletebot - Delete your bot\n" + "/token - Get bot token\n" + "/help - Show this message") + + elif text.startswith("/newbot"): + name = text.replace("/newbot", "").strip() + if not name: + send(server, token, chat_id, "Usage: /newbot \nExample: /newbot WeatherBot") + return + + if len(name) < 3 or len(name) > 32: + send(server, token, chat_id, "Bot name must be 3-32 characters.") + return + + if not name.lower().endswith("bot"): + send(server, token, chat_id, "Bot name must end with 'bot' or 'Bot'.") + return + + # Create the bot via internal API + fp = os.urandom(16).hex() + resp = api(server, token, "../register", { + "name": name, + "fingerprint": fp, + "botfather_token": token, + "owner": from_id + }) + + if resp.get("ok"): + result = resp["result"] + send(server, token, chat_id, + f"Done! Your new bot @{result.get('alias', name.lower())} is ready.\n\n" + f"Token: {result['token']}\n\n" + f"Keep this token secret!") + else: + send(server, token, chat_id, f"Failed: {resp.get('description', 'unknown error')}") + + elif text == "/mybots": + send(server, token, chat_id, + "Use the built-in /mybots via chat with @botfather.\n" + "(The built-in handler tracks ownership.)") + + elif text.startswith("/deletebot"): + name = text.replace("/deletebot", "").strip() + if not name: + send(server, token, chat_id, "Usage: /deletebot ") + return + send(server, token, chat_id, + f"Use the built-in /deletebot {name} via chat with @botfather.\n" + "(The built-in handler verifies ownership.)") + + elif text.startswith("/token"): + name = text.replace("/token", "").strip() + if not name: + send(server, token, chat_id, "Usage: /token ") + return + send(server, token, chat_id, + f"Use the built-in /token {name} via chat with @botfather.\n" + "(The built-in handler verifies ownership.)") + + else: + send(server, token, chat_id, "Unknown command. Try /help") + + +def main(): + parser = argparse.ArgumentParser(description="featherChat BotFather (standalone)") + parser.add_argument("--server", required=True, help="featherChat server URL") + parser.add_argument("--token", help="BotFather token (or set BOTFATHER_TOKEN env)") + args = parser.parse_args() + + token = args.token or os.environ.get("BOTFATHER_TOKEN", "") + + # Try loading from file + if not token and os.path.exists(TOKEN_FILE): + token = open(TOKEN_FILE).read().strip() + + if not token: + token = register_botfather(args.server) + + # Save token + with open(TOKEN_FILE, "w") as f: + f.write(token) + + # Verify + me = api(args.server, token, "getMe") + if not me.get("ok"): + print(f"ERROR: Invalid token. Delete {TOKEN_FILE} and retry.") + sys.exit(1) + + bot_name = me["result"].get("first_name", "BotFather") + print(f"BotFather ({bot_name}) running") + print(f"Server: {args.server}") + print(f"Polling for messages...") + print() + + offset = 0 + while True: + try: + resp = api(args.server, token, "getUpdates", {"offset": offset, "timeout": 30}) + for update in resp.get("result", []): + offset = update["update_id"] + 1 + msg = update.get("message", {}) + if msg: + handle_message(args.server, token, msg) + except KeyboardInterrupt: + print("\nBotFather stopped.") + break + except Exception as e: + print(f"Error: {e}") + time.sleep(3) + + +if __name__ == "__main__": + main() From 878847ce8967b76ddda0637f9e47895f5ef5723f Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 11:11:28 +0400 Subject: [PATCH 21/50] fix: recognize @botfather as bot peer (special case, not pattern change) Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-client/src/tui/commands.rs | 2 +- warzone/crates/warzone-server/src/routes/web.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 3fc5219..b77a84b 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -546,7 +546,7 @@ impl App { let url = format!("{}/v1/alias/whois/{}", client.base_url, normfp(&peer)); match client.client.get(&url).send().await { Ok(resp) => resp.json::().await.ok() - .and_then(|d| d.get("alias").and_then(|a| a.as_str().map(|s| s.ends_with("bot") || s.ends_with("Bot") || s.ends_with("_bot")))) + .and_then(|d| d.get("alias").and_then(|a| a.as_str().map(|s| s.ends_with("bot") || s.ends_with("Bot") || s.ends_with("_bot") || s == "botfather"))) .unwrap_or(false), Err(_) => false, } diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 5d6276b..ca42e43 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -1210,7 +1210,7 @@ async function doSend() { try { const wr = await fetch(SERVER + '/v1/alias/whois/' + normFP(peer)); const wd = await wr.json(); - if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot'))) isBotPeer = true; + if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot') || wd.alias === 'botfather')) isBotPeer = true; } catch(e) {} if (isBotPeer) { From 76ee2ab5852fb60d3e001d55c36cf714ea34f779 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 11:14:41 +0400 Subject: [PATCH 22/50] fix: BotFather alias record ensures resolve_alias works after data wipe Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-server/src/main.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 55c07cf..554fbdc 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -82,6 +82,18 @@ async fn main() -> anyhow::Result<()> { } else { tracing::info!("BotFather already exists"); } + // Always ensure alias exists (may have been lost on data wipe) + let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather"); + // Store proper AliasRecord so resolve_alias works + let bf_record = serde_json::json!({ + "alias": "botfather", + "fingerprint": botfather_fp, + "recovery_key": "", + "registered_at": chrono::Utc::now().timestamp(), + "last_active": chrono::Utc::now().timestamp(), + }); + let _ = state.db.aliases.insert(b"rec:botfather", serde_json::to_vec(&bf_record).unwrap_or_default()); } // Spawn federation outgoing WS connection if enabled From e0e747e0059a703fea3f35b4dceeafb7743c3537 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 11:17:05 +0400 Subject: [PATCH 23/50] fix: BotFather fingerprint uses all-hex (00000000000000000b0ffa00e000000f) Old fp contained non-hex chars (o,r) which got stripped by normFP, causing whois lookup failure and bot detection to miss. Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-server/src/botfather.rs | 2 +- warzone/crates/warzone-server/src/main.rs | 2 +- warzone/crates/warzone-server/src/routes/bot.rs | 2 +- warzone/crates/warzone-server/src/state.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/warzone/crates/warzone-server/src/botfather.rs b/warzone/crates/warzone-server/src/botfather.rs index 9d7ee9d..bdabf61 100644 --- a/warzone/crates/warzone-server/src/botfather.rs +++ b/warzone/crates/warzone-server/src/botfather.rs @@ -5,7 +5,7 @@ use crate::state::AppState; -const BOTFATHER_FP: &str = "0000000000000000botfather00000000"; +const BOTFATHER_FP: &str = "00000000000000000b0ffa00e000000f"; /// Check if a message is destined for BotFather and handle it. /// Called from deliver_or_queue when the recipient is the BotFather fingerprint. diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 554fbdc..c5d65a9 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -60,7 +60,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Bot API enabled"); // Auto-create BotFather if it doesn't exist - let botfather_fp = "0000000000000000botfather00000000"; + let botfather_fp = "00000000000000000b0ffa00e000000f"; let botfather_key = format!("bot_fp:{}", botfather_fp); if state.db.tokens.get(botfather_key.as_bytes()).ok().flatten().is_none() { let token = format!("botfather:{}", hex::encode(rand::random::<[u8; 16]>())); diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index b0abdf8..05b7072 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -258,7 +258,7 @@ async fn register_bot( // Only BotFather can register bots // Require botfather_token field matching the stored BotFather token if let Some(ref bf_token) = req.botfather_token { - let botfather_fp = "0000000000000000botfather00000000"; + let botfather_fp = "00000000000000000b0ffa00e000000f"; let bf_key = format!("bot_fp:{}", botfather_fp); let stored_token = state.db.tokens.get(bf_key.as_bytes()) .ok().flatten() diff --git a/warzone/crates/warzone-server/src/state.rs b/warzone/crates/warzone-server/src/state.rs index d5ff857..e3f1374 100644 --- a/warzone/crates/warzone-server/src/state.rs +++ b/warzone/crates/warzone-server/src/state.rs @@ -174,7 +174,7 @@ impl AppState { /// Returns true if delivered instantly (local or remote). pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool { // BotFather: intercept messages to @botfather - if self.bots_enabled && to_fp == "0000000000000000botfather00000000" { + if self.bots_enabled && to_fp == "00000000000000000b0ffa00e000000f" { // Extract sender from message if let Ok(msg) = serde_json::from_slice::(message) { let from = msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); From 76fd8dd81a395f19f89742679ade59d2aab8b2fc Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 11:19:34 +0400 Subject: [PATCH 24/50] fix: web bot detection checks alias name first, then whois fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- .../crates/warzone-server/src/routes/web.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index ca42e43..35695a2 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -1207,11 +1207,20 @@ async function doSend() { // Check if peer is a bot — send plaintext instead of E2E let isBotPeer = false; - try { - const wr = await fetch(SERVER + '/v1/alias/whois/' + normFP(peer)); - const wd = await wr.json(); - if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot') || wd.alias === 'botfather')) isBotPeer = true; - } catch(e) {} + const peerRaw = $peerInput.value.trim(); + // Check by alias name if peer was set via @alias + if (peerRaw.startsWith('@')) { + const aname = peerRaw.slice(1).toLowerCase(); + isBotPeer = aname.endsWith('bot') || aname.endsWith('_bot') || aname === 'botfather'; + } + // Also check by fingerprint reverse-lookup + if (!isBotPeer) { + try { + const wr = await fetch(SERVER + '/v1/alias/whois/' + peer); + const wd = await wr.json(); + if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot') || wd.alias === 'botfather')) isBotPeer = true; + } catch(e) {} + } if (isBotPeer) { const msgId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(); From 4118be7ef3076862c19a83616380cd30de438899 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 11:27:13 +0400 Subject: [PATCH 25/50] docs: update LLM bot dev guide with BotFather chat flow + plaintext auto-detect Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/docs/LLM_BOT_DEV.md | 303 ++++++++++++++++++------------------ warzone/docs/LLM_HELP.md | 18 ++- 2 files changed, 166 insertions(+), 155 deletions(-) diff --git a/warzone/docs/LLM_BOT_DEV.md b/warzone/docs/LLM_BOT_DEV.md index 0e81e0e..182dab2 100644 --- a/warzone/docs/LLM_BOT_DEV.md +++ b/warzone/docs/LLM_BOT_DEV.md @@ -1,126 +1,140 @@ # featherChat Bot Development Reference -## Setup +## Prerequisites -Server: `http://HOST:7700` -All bot endpoints: `/v1/bot//METHOD` - -**Prerequisites:** The server must be started with `--enable-bots` to activate bot functionality. - -### BotFather Registration - -Bots can only be created through `@botfather`. On first server start, BotFather is auto-created and its token is printed in the server logs. - -To create a bot: -1. Message `@botfather` in the chat client (or use the BotFather token from server logs for programmatic access). -2. BotFather calls `/v1/bot/register` with your request, including a `botfather_token` field for authorization. - -Registration request (sent by BotFather internally): -``` -POST /v1/bot/register -{"name":"MyBot","fingerprint":"any_32_hex_chars","botfather_token":""} -→ {"ok":true,"result":{"token":"TOKEN","alias":"@mybot_bot","owner":""}} +Server must run with `--enable-bots`: +```bash +warzone-server --bind 0.0.0.0:7700 --enable-bots ``` -Bot names must end with Bot/bot/_bot. Token format: `:`. +## Creating a Bot -### E2E Bot Mode +Message `@botfather` in the chat client (TUI or web): -Bots can optionally participate in E2E encryption. Pass additional fields during registration: - -```json -{ - "name": "SecureBot", - "fingerprint": "...", - "botfather_token": "...", - "e2e": true, - "bundle": { "identity_key": "...", "signed_prekey": "...", "signature": "...", "one_time_prekeys": ["..."] }, - "eth_address": "0x..." -} +``` +You: /peer @botfather +You: /newbot MyAssistantBot +BotFather: Done! Your new bot @myassistantbot is ready. + Token: a1b2c3d4e5f6a7b8:9876543210abcdef... + Keep this token secret! ``` -An E2E bot registers a full prekey bundle and can establish X3DH sessions with users, receiving decryptable messages instead of `raw_encrypted` blobs. +BotFather commands: +- `/newbot ` — create bot (name must end with bot/Bot) +- `/mybots` — list your bots +- `/deletebot ` — delete bot you own +- `/token ` — show token for your bot +- `/help` — show commands + +## How Users Message Bots + +When a user messages a bot alias (`@*bot`, `@*Bot`, `@*_bot`, `@botfather`), the client **automatically sends plaintext** — no E2E encryption. The bot receives readable `text` in getUpdates. + +This is automatic — no configuration needed. The client detects the bot alias suffix. + +## API Base + +``` +http://SERVER:7700/v1/bot/TOKEN/METHOD +``` ## Endpoints ### getMe ``` GET /v1/bot/TOKEN/getMe -→ {"ok":true,"result":{"id":"fp","is_bot":true,"first_name":"MyBot","username":"MyBot"}} +→ {"ok":true,"result":{"id":123456,"id_str":"aabbccdd...","is_bot":true,"first_name":"MyBot"}} ``` -### getUpdates (long-poll) +### getUpdates ``` POST /v1/bot/TOKEN/getUpdates -{"offset":LAST_UPDATE_ID+1,"timeout":30,"limit":100} -→ {"ok":true,"result":[{"update_id":N,"message":{...}}]} +{"offset":LAST_ID+1,"timeout":50,"limit":100} ``` -offset: skip updates with id < offset (acknowledge processed) -timeout: long-poll seconds (max 30) -limit: max updates to return (default 100) +Response: +```json +{"ok":true,"result":[ + {"update_id":1,"message":{ + "message_id":"uuid", + "from":{"id":123456,"id_str":"sender_fp_hex","is_bot":false}, + "chat":{"id":123456,"id_str":"sender_fp_hex","type":"private"}, + "date":1711612800, + "text":"Hello bot!" + }} +]} +``` + +**Fields:** +- `offset` — skip updates < offset (acknowledge processed). **Always use this.** +- `timeout` — long-poll seconds (max 50, matches Telegram) +- `limit` — max updates (default 100) +- `from.id` — numeric (i64 hash of fingerprint, for TG library compat) +- `from.id_str` — hex fingerprint string ### sendMessage ``` POST /v1/bot/TOKEN/sendMessage { - "chat_id": "FINGERPRINT", + "chat_id": "fingerprint_hex_or_numeric_id", "text": "Hello!", - "parse_mode": "HTML", // optional - "reply_to_message_id": "MSG_ID", // optional - "reply_markup": { // optional, inline keyboard + "parse_mode": "HTML", + "reply_to_message_id": "msg_uuid", + "reply_markup": { "inline_keyboard": [ [{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}] ] } } -→ {"ok":true,"result":{"message_id":"UUID","delivered":true}} +→ {"ok":true,"result":{"message_id":"uuid","delivered":true}} +``` + +`chat_id` accepts: hex fingerprint string, numeric i64, or `0x` ETH address. +`parse_mode` "HTML" renders ``, ``, ``, `` in web client. + +### editMessageText +``` +POST /v1/bot/TOKEN/editMessageText +{"chat_id":"..","message_id":"uuid","text":"Updated","reply_markup":{...}} ``` ### answerCallbackQuery ``` POST /v1/bot/TOKEN/answerCallbackQuery -{"callback_query_id":"ID","text":"Done!","show_alert":false} +{"callback_query_id":"id","text":"Done!","show_alert":false} → {"ok":true,"result":true} ``` -### editMessageText -``` -POST /v1/bot/TOKEN/editMessageText -{"chat_id":"FP","message_id":"MSG_ID","text":"Updated text","reply_markup":{...}} -``` - ### sendDocument ``` POST /v1/bot/TOKEN/sendDocument -{"chat_id":"FP","document":"filename_or_url","caption":"optional"} +{"chat_id":"..","document":"filename_or_url","caption":"optional"} ``` -### setWebhook / deleteWebhook / getWebhookInfo +### Webhooks ``` -POST /v1/bot/TOKEN/setWebhook {"url":"https://mybot.example.com/webhook"} +POST /v1/bot/TOKEN/setWebhook {"url":"https://mybot.example.com/hook"} POST /v1/bot/TOKEN/deleteWebhook GET /v1/bot/TOKEN/getWebhookInfo ``` +When set, updates are POSTed to the URL instead of queued for getUpdates. + ## Update Types -Messages from users arrive in getUpdates as: - -**Plaintext (from other bots):** +**User message (plaintext — default for bot recipients):** ```json -{"update_id":1,"message":{"message_id":"id","from":{"id":"fp","is_bot":true},"chat":{"id":"fp","type":"private"},"text":"Hello"}} +{"update_id":1,"message":{"message_id":"id","from":{"id":123,"id_str":"fp"},"chat":{"id":123,"id_str":"fp","type":"private"},"text":"Hello bot!","date":1234567890}} ``` -**Encrypted (from users with E2E sessions):** +**Bot-to-bot message:** ```json -{"update_id":2,"message":{"message_id":"id","from":{"id":"fp","is_bot":false},"chat":{"id":"fp"},"text":null,"raw_encrypted":"base64..."}} +{"update_id":2,"message":{"message_id":"id","from":{"id":456,"is_bot":true},"chat":{"id":456,"type":"private"},"text":"inter-bot msg","date":1234567890}} ``` -Note: v1 bots cannot decrypt E2E messages. They see text=null + raw_encrypted blob. -**Call signal:** +**E2E encrypted (user sent without bot detection — rare):** ```json -{"update_id":3,"message":{"text":"/call_Offer","call_signal":{"type":"Offer","payload":"..."}}} +{"update_id":3,"message":{"text":null,"raw_encrypted":"base64..."}} ``` **File:** @@ -128,28 +142,29 @@ Note: v1 bots cannot decrypt E2E messages. They see text=null + raw_encrypted bl {"update_id":4,"message":{"document":{"file_name":"report.pdf","file_size":1234}}} ``` -## Python Examples +## Python Echo Bot -### Echo Bot ```python import requests, time -TOKEN = "YOUR_TOKEN" +TOKEN = "YOUR_TOKEN" # from @botfather /newbot API = f"http://localhost:7700/v1/bot/{TOKEN}" offset = 0 while True: - resp = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json() - for update in resp.get("result", []): - offset = update["update_id"] + 1 - msg = update.get("message", {}) - chat_id = msg.get("chat", {}).get("id", "") + r = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 50}).json() + for u in r.get("result", []): + offset = u["update_id"] + 1 + msg = u.get("message", {}) text = msg.get("text") + chat_id = msg.get("chat", {}).get("id", "") if text and chat_id: requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"Echo: {text}"}) + time.sleep(0.1) ``` -### Inline Keyboard Bot +## Python Menu Bot (Inline Keyboard) + ```python import requests @@ -157,101 +172,93 @@ TOKEN = "YOUR_TOKEN" API = f"http://localhost:7700/v1/bot/{TOKEN}" offset = 0 -def send_menu(chat_id): +def menu(chat_id): requests.post(f"{API}/sendMessage", json={ - "chat_id": chat_id, - "text": "Choose an option:", - "reply_markup": { - "inline_keyboard": [ - [{"text": "Option A", "callback_data": "a"}, {"text": "Option B", "callback_data": "b"}], - [{"text": "Help", "callback_data": "help"}] - ] - } + "chat_id": chat_id, "text": "Pick one:", + "reply_markup": {"inline_keyboard": [ + [{"text": "A", "callback_data": "a"}, {"text": "B", "callback_data": "b"}] + ]} }) while True: - resp = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).json() - for update in resp.get("result", []): - offset = update["update_id"] + 1 - msg = update.get("message", {}) - text = msg.get("text", "") - chat_id = msg.get("chat", {}).get("id", "") - if text == "/start": - send_menu(chat_id) - elif text: - requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"You said: {text}"}) + r = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 50}).json() + for u in r.get("result", []): + offset = u["update_id"] + 1 + msg = u.get("message", {}) + text, cid = msg.get("text", ""), msg.get("chat", {}).get("id", "") + if text == "/start": menu(cid) + elif text: requests.post(f"{API}/sendMessage", json={"chat_id": cid, "text": f"You said: {text}"}) ``` -### Node.js Echo Bot +## Node.js Echo Bot + ```javascript -const API = `http://localhost:7700/v1/bot/${process.env.BOT_TOKEN}`; +const TOKEN = process.env.BOT_TOKEN; +const API = `http://localhost:7700/v1/bot/${TOKEN}`; let offset = 0; -async function poll() { +(async () => { while (true) { try { - const res = await fetch(`${API}/getUpdates`, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({offset, timeout: 30}) - }); - const data = await res.json(); - for (const update of data.result || []) { - offset = update.update_id + 1; - const msg = update.message; - if (msg?.text && msg?.chat?.id) { + const r = await (await fetch(`${API}/getUpdates`, { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({offset, timeout: 50}) + })).json(); + for (const u of r.result || []) { + offset = u.update_id + 1; + const {text, chat} = u.message || {}; + if (text && chat?.id) await fetch(`${API}/sendMessage`, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({chat_id: msg.chat.id, text: `Echo: ${msg.text}`}) + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({chat_id: chat.id, text: `Echo: ${text}`}) }); - } } - } catch (e) { console.error(e); await new Promise(r => setTimeout(r, 3000)); } + } catch(e) { console.error(e); await new Promise(r => setTimeout(r, 3000)); } } -} -poll(); +})(); ``` +## Bot Bridge (TG Library Compatibility) + +For unmodified Telegram bots (python-telegram-bot, aiogram, Telegraf): + +```bash +python3 tools/bot-bridge.py --server http://localhost:7700 --token YOUR_TOKEN --port 8081 +``` + +Then point your TG bot at the bridge: +```python +# python-telegram-bot +from telegram import Bot +bot = Bot(token="TOKEN", base_url="http://localhost:8081/botTOKEN") + +# Telegraf (Node.js) +const bot = new Telegraf("TOKEN", { telegram: { apiRoot: "http://localhost:8081" } }) +``` + +The bridge translates numeric chat_id ↔ fingerprints automatically. + ## Differences from Telegram | Feature | Telegram | featherChat | -|---------|----------|------------| -| chat_id | numeric | hex fingerprint string OR numeric (both accepted) | +|---------|----------|-------------| +| chat_id | integer | string fp, numeric, or 0x ETH (all accepted) | +| User→bot messages | plaintext | plaintext (auto-detected by client) | +| Bot creation | @BotFather chat | @botfather chat (same flow) | | getUpdates timeout | up to 50s | up to 50s | -| User messages | plaintext | E2E encrypted (text=null unless E2E bot) | -| Bot messages | plaintext | plaintext (no E2E) unless E2E bot mode | -| File upload | multipart form | JSON reference (v1) | -| Inline keyboards | full support | stored + delivered, no popup | -| Callback queries | full popup | acknowledged, no popup | -| Webhooks | full HTTPS | URL stored, updates delivered live (POST to webhook URL) | -| Media groups | supported | not yet | -| parse_mode | renders HTML/MD | HTML rendered (, , , ) | +| from.id | integer | integer (hash of fp) + id_str (hex fp) | +| File upload | multipart | JSON reference (v1) | +| Inline keyboards | full | stored + delivered, no popup | +| Webhooks | HTTPS POST | HTTP POST (delivered live) | +| parse_mode HTML | rendered | rendered in web client | +| Media groups | yes | not yet | -## Key Patterns +## Key Rules -**Always use offset** — without it, the same messages are returned every poll. - -**chat_id is the sender's fingerprint** — use `msg.chat.id` or `msg.from.id`. Note: `from.id` is now a numeric integer for TG compatibility; use `from.id_str` for the hex fingerprint. - -**Bot alias** — users message bots via `@mybot_bot` which resolves to the bot's fingerprint. - -**Error handling** — all responses have `{"ok": bool}`. Check `ok` before accessing `result`. - -**Rate limits** — 200 concurrent server requests, no per-bot limit (be reasonable). - -## Bot Bridge (`tools/bot-bridge.py`) - -The bot bridge provides a compatibility layer for existing Telegram bot libraries. It translates between featherChat's Bot API and standard TG libraries. - -**Supported libraries:** -- **python-telegram-bot** — set `base_url` to `http://your-server:7700/v1/bot/` -- **aiogram** — configure the bot session with the featherChat server URL -- **Telegraf (Node.js)** — set `telegram.apiRoot` to `http://your-server:7700/v1/bot` - -Usage: -```bash -python tools/bot-bridge.py --token YOUR_BOT_TOKEN --server http://localhost:7700 -``` - -The bridge handles differences like fingerprint-based chat_id, numeric ID translation, and webhook forwarding. +1. **Always use offset** in getUpdates — without it you reprocess messages +2. **chat_id** — use `msg.chat.id` (numeric) or `msg.chat.id_str` (hex fingerprint) +3. **Bot names** must end with `bot`, `Bot`, or `_bot` +4. **Only @botfather** can create bots — direct API registration requires botfather_token +5. **Server needs --enable-bots** — without it all bot endpoints return 403 +6. **Plaintext by default** — user clients auto-detect bot aliases and skip E2E +7. **E2E bots** — register with `e2e:true` + bundle for encrypted sessions (advanced) diff --git a/warzone/docs/LLM_HELP.md b/warzone/docs/LLM_HELP.md index 0b0e408..70af2e2 100644 --- a/warzone/docs/LLM_HELP.md +++ b/warzone/docs/LLM_HELP.md @@ -96,7 +96,8 @@ Groups auto-create on join if they don't exist. Server fans out per-member encry - Server sees: metadata (who talks to whom, timestamps), encrypted blobs, presence. - Server cannot read msg content. - Pre-keys: signed pre-key + 10 one-time pre-keys uploaded on register. -- Bot API msgs are NOT E2E encrypted (plaintext JSON envelopes). +- Bot msgs: clients auto-detect bot aliases, send plaintext (no E2E). Server can read bot msgs. +- E2E bots possible (register with seed+bundle) but standard bots are plaintext. ## Federation @@ -131,15 +132,18 @@ no prekeys available | recipient's one-time prekeys exhausted | they need to re- ## Bot API (Telegram-compatible) -### BotFather +### Creating a Bot -`@botfather` is the only way to create bots. It is auto-created on first server start (token printed in server logs). Users message `@botfather` to register new bots. The server must be started with `--enable-bots` to activate bot functionality. +Server must run with `--enable-bots`. Then in chat: +``` +/peer @botfather +/newbot MyWeatherBot +→ BotFather replies with token +``` -### Registration +BotFather commands: /newbot, /mybots, /deletebot , /token , /help -Bots are created via BotFather, which calls POST /v1/bot/register with a `botfather_token` field. Each bot has an `owner` field linking it to the user who requested creation. - -Bot aliases must end with Bot, bot, or _bot. Non-bots cannot use these. +Bot names must end with bot/Bot/_bot. Only @botfather can create bots. ### Plaintext Bot Messaging From b0fa9f92bd465f5bdea38294f2b02f71e9735f94 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 12:21:45 +0400 Subject: [PATCH 26/50] fix: BotFather stores rec: AliasRecord so resolve_alias finds bot aliases Co-Authored-By: Claude Opus 4.6 (1M context) --- .../crates/warzone-server/src/botfather.rs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/warzone/crates/warzone-server/src/botfather.rs b/warzone/crates/warzone-server/src/botfather.rs index bdabf61..db48c08 100644 --- a/warzone/crates/warzone-server/src/botfather.rs +++ b/warzone/crates/warzone-server/src/botfather.rs @@ -124,15 +124,17 @@ async fn handle_newbot(state: &AppState, owner_fp: &str, text: &str) -> String { let fp_key = format!("bot_fp:{}", fp); let _ = state.db.tokens.insert(fp_key.as_bytes(), token.as_bytes()); - // Register alias - let _ = state - .db - .aliases - .insert(alias_key.as_bytes(), fp.as_bytes()); - let _ = state - .db - .aliases - .insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes()); + // Register alias (all 3 keys needed for resolve_alias to work) + let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes()); + let alias_record = serde_json::json!({ + "alias": name_lower, + "fingerprint": fp, + "recovery_key": "", + "registered_at": chrono::Utc::now().timestamp(), + "last_active": chrono::Utc::now().timestamp(), + }); + let _ = state.db.aliases.insert(format!("rec:{}", name_lower).as_bytes(), serde_json::to_vec(&alias_record).unwrap_or_default()); tracing::info!( "BotFather: created bot @{} for owner {}", From 8b37bd432395e08220ab221aa5dcc5c1fb6bd115 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 12:25:41 +0400 Subject: [PATCH 27/50] fix: getUpdates enforces min 1s delay when empty (prevents tight-loop spam) Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-server/src/routes/bot.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 05b7072..5cba6c7 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -453,8 +453,9 @@ async fn get_updates( // Step 3: Collect remaining updates up to `limit`. let updates = collect_updates(&state, bot_fp, limit); - // Step 4: Long-poll if empty. - if updates.is_empty() && timeout > 0 { + // Step 4: Long-poll if empty. Minimum 1s delay to prevent tight-loop abuse. + if updates.is_empty() { + let timeout = if timeout == 0 { 1 } else { timeout }; // force min 1s let wait = std::cmp::min(timeout, 50); // Poll in 1-second intervals so new messages are picked up promptly. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait); From 6fee73fc4d62798052e720ce7094bd59a1d867ab Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 12:36:00 +0400 Subject: [PATCH 28/50] v0.0.30: markdown rendering in web, fix scrolling Web: - Markdown renderer: **bold**, *italic*, `code`, ```code blocks```, # headers, [links](url), > blockquotes, - lists - All message text rendered as markdown (bot messages look great now) - Fixed scroll: overflow-y: scroll + min-height: 0 on messages container - CSS for code blocks, pre, headers, blockquotes, lists - Styled: code=cyan bg, pre=dark bg+border, bold=white, italic=amber Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 ++-- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 46 +++++++++++++++++-- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 2e4edba..b2495bc 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.29" +version = "0.0.30" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.29" +version = "0.0.30" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.29" +version = "0.0.30" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.29" +version = "0.0.30" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.29" +version = "0.0.30" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 8c962bf..2b922d9 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.29" +version = "0.0.30" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index fdea9bb..a22607d 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.29" +version = "0.0.30" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 35695a2..b8dd28f 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v11'; +const CACHE = 'wz-v12'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -154,8 +154,18 @@ const WEB_HTML: &str = r##" #chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px; border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; } - #messages { flex: 1; overflow-y: auto; padding: 8px 10px; -webkit-overflow-scrolling: touch; } + #messages { flex: 1; overflow-y: scroll; padding: 8px 10px; -webkit-overflow-scrolling: touch; min-height: 0; } .msg { padding: 2px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; } + .msg code { background: #1a1a3e; padding: 1px 4px; border-radius: 3px; font-size: 0.95em; color: #4fc3f7; } + .msg pre { background: #0d0d20; padding: 8px; border-radius: 4px; margin: 4px 0; overflow-x: auto; border: 1px solid #222; } + .msg pre code { background: none; padding: 0; } + .msg strong, .msg b { color: #fff; } + .msg em, .msg i { color: #e6a23c; } + .msg a { color: #4fc3f7; } + .msg blockquote { border-left: 3px solid #444; padding-left: 8px; color: #888; margin: 4px 0; } + .msg ul, .msg ol { padding-left: 20px; margin: 4px 0; } + .msg h1, .msg h2, .msg h3 { color: #fff; margin: 6px 0 2px; } + .msg h1 { font-size: 1.2em; } .msg h2 { font-size: 1.1em; } .msg h3 { font-size: 1em; } .msg .ts { color: #333; margin-right: 4px; } .msg .from-self { color: #4ade80; font-weight: bold; } .msg .from-sys { color: #5e9ca0; font-style: italic; } @@ -241,7 +251,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.29'; +const VERSION = '0.0.30'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -737,6 +747,34 @@ function esc(s) { return d.innerHTML; } +function renderMd(text) { + let s = esc(text); + // Code blocks: ```...``` + s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, '
$2
'); + // Inline code: `...` + s = s.replace(/`([^`]+)`/g, '$1'); + // Bold: **...** + s = s.replace(/\*\*(.+?)\*\*/g, '$1'); + // Italic: *...* + s = s.replace(/(?$1'); + // Headers: ### ... (at line start) + s = s.replace(/^### (.+)$/gm, '

$1

'); + s = s.replace(/^## (.+)$/gm, '

$1

'); + s = s.replace(/^# (.+)$/gm, '

$1

'); + // Links: [text](url) + s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
$1'); + // Blockquotes: > ... + s = s.replace(/^> (.+)$/gm, '
$1
'); + // Unordered lists: - ... + s = s.replace(/^- (.+)$/gm, '
  • $1
  • '); + s = s.replace(/(
  • .*<\/li>\n?)+/g, '
      $&
    '); + // Line breaks + s = s.replace(/\n/g, '
    '); + // Clean up br inside pre + s = s.replace(/
    ([\s\S]*?)<\/code><\/pre>/g, (m, code) => '
    ' + code.replace(/
    /g, '\n') + '
    '); + return s; +} + const PEER_COLORS = ['#e6a23c','#f56c9d','#67c7eb','#b39ddb','#ff8a65','#81c784','#ce93d8','#4fc3f7','#ffb74d','#aed581','#f06292','#4dd0e1']; function peerColor(name) { @@ -813,7 +851,7 @@ function addMsg(from, text, isSelf, messageId, rawHtml) { const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent'; receiptHtml = ' ' + receiptIndicator(status) + ''; } - const bodyHtml = rawHtml ? text : makeAddressClickable(esc(text)); + const bodyHtml = rawHtml ? text : makeAddressClickable(renderMd(text)); d.innerHTML = '' + ts() + ' ' + lock + '' + makeAddressClickable(esc(from)) + ': ' + bodyHtml + receiptHtml; // Attach click handler for .addr spans d.querySelectorAll('.addr').forEach(el => { From 3e583bb04ba4a9bb78fcc57698ccc977dc6cdcda Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 13:49:10 +0400 Subject: [PATCH 29/50] v0.0.31: per-bot unique user IDs, remove raw fingerprint from bot API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy: from.id is now Hash(bot_token + user_fp) → different bots see different numeric IDs for the same user. Prevents cross-bot user correlation. Removed id_str (raw hex fingerprint) from all bot API responses. Updated LLM_BOT_DEV.md and LLM_HELP.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +-- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/bot.rs | 65 ++++++++----------- .../warzone-server/src/routes/resolve.rs | 17 ++++- .../crates/warzone-server/src/routes/web.rs | 4 +- warzone/docs/LLM_BOT_DEV.md | 12 ++-- warzone/docs/LLM_HELP.md | 2 +- 8 files changed, 59 insertions(+), 55 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index b2495bc..b751509 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.30" +version = "0.0.31" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.30" +version = "0.0.31" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.30" +version = "0.0.31" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.30" +version = "0.0.31" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.30" +version = "0.0.31" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 2b922d9..43f7bfc 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.30" +version = "0.0.31" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index a22607d..9a744a0 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.30" +version = "0.0.31" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 5cba6c7..0764a84 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -171,9 +171,9 @@ pub async fn try_bot_webhook(state: &AppState, to_fp: &str, message: &[u8]) -> b let update = if let Ok(wire) = bincode::deserialize::(message) { - wire_message_to_update(&wire, message) + wire_message_to_update(&wire, message, &token) } else if let Ok(bot_msg) = serde_json::from_slice::(message) { - bot_json_to_update(&bot_msg) + bot_json_to_update(&bot_msg, &token) } else { None }; @@ -367,8 +367,7 @@ async fn get_me( Json(serde_json::json!({ "ok": true, "result": { - "id": crate::routes::resolve::fp_to_numeric_id(fp), - "id_str": fp, + "id": crate::routes::resolve::fp_to_numeric_id_for_bot(fp, &token), "is_bot": true, "first_name": info["name"], "username": info["name"], @@ -423,7 +422,7 @@ async fn get_updates( let timeout = params.timeout.unwrap_or(0); // Step 1: Migrate raw queue entries into the persistent bot_queue. - migrate_raw_queue(&state, bot_fp); + migrate_raw_queue(&state, bot_fp, &token); // Step 2: If offset is provided, delete all acknowledged updates (update_id < offset). if let Some(offset) = params.offset { @@ -462,7 +461,7 @@ async fn get_updates( loop { tokio::time::sleep(std::time::Duration::from_secs(1)).await; // Check for newly arrived raw messages. - migrate_raw_queue(&state, bot_fp); + migrate_raw_queue(&state, bot_fp, &token); let polled = collect_updates(&state, bot_fp, limit); if !polled.is_empty() { return Json(serde_json::json!({ @@ -486,7 +485,7 @@ async fn get_updates( /// /// Each raw entry is converted into a Telegram-style Update JSON object, assigned /// a persistent update_id, and stored. The original raw key is deleted. -fn migrate_raw_queue(state: &AppState, bot_fp: &str) { +fn migrate_raw_queue(state: &AppState, bot_fp: &str, bot_token: &str) { let prefix = format!("queue:{}", bot_fp); let mut keys_to_delete = Vec::new(); @@ -499,9 +498,9 @@ fn migrate_raw_queue(state: &AppState, bot_fp: &str) { let update = if let Ok(wire) = bincode::deserialize::(&value) { - wire_message_to_update(&wire, &value) + wire_message_to_update(&wire, &value, bot_token) } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { - bot_json_to_update(&bot_msg) + bot_json_to_update(&bot_msg, bot_token) } else { None }; @@ -521,6 +520,7 @@ fn migrate_raw_queue(state: &AppState, bot_fp: &str) { fn wire_message_to_update( wire: &warzone_protocol::message::WireMessage, raw_bytes: &[u8], + bot_token: &str, ) -> Option { match wire { warzone_protocol::message::WireMessage::Message { @@ -529,20 +529,18 @@ fn wire_message_to_update( .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); - let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, - "id_str": sender_fingerprint, - "is_bot": false, + "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, - "id_str": sender_fingerprint, - "type": "private", + "type": "private", }, "date": chrono::Utc::now().timestamp(), "text": null, @@ -556,20 +554,18 @@ fn wire_message_to_update( .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); - let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, - "id_str": sender_fingerprint, - "is_bot": false, + "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, - "id_str": sender_fingerprint, - "type": "private", + "type": "private", }, "date": chrono::Utc::now().timestamp(), "text": null, @@ -584,20 +580,18 @@ fn wire_message_to_update( payload, .. } => { - let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, - "id_str": sender_fingerprint, - "is_bot": false, + "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, - "id_str": sender_fingerprint, - "type": "private", + "type": "private", }, "date": chrono::Utc::now().timestamp(), "text": format!("/call_{:?}", signal_type), @@ -615,20 +609,18 @@ fn wire_message_to_update( file_size, .. } => { - let numeric = crate::routes::resolve::fp_to_numeric_id(sender_fingerprint); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); Some(serde_json::json!({ "message": { "message_id": id, "from": { "id": numeric, - "id_str": sender_fingerprint, - "is_bot": false, + "is_bot": false, "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], }, "chat": { "id": numeric, - "id_str": sender_fingerprint, - "type": "private", + "type": "private", }, "date": chrono::Utc::now().timestamp(), "document": { @@ -645,24 +637,22 @@ fn wire_message_to_update( } /// Convert a plaintext bot JSON message into a Telegram-style update (without update_id). -fn bot_json_to_update(bot_msg: &serde_json::Value) -> Option { +fn bot_json_to_update(bot_msg: &serde_json::Value, bot_token: &str) -> Option { let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?; match msg_type { "bot_message" => { let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); - let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); Some(serde_json::json!({ "message": { "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), "from": { "id": numeric, - "id_str": from_fp, - "is_bot": true, + "is_bot": true, }, "chat": { "id": numeric, - "id_str": from_fp, - "type": "private", + "type": "private", }, "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), "text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), @@ -671,14 +661,13 @@ fn bot_json_to_update(bot_msg: &serde_json::Value) -> Option } "callback_query" => { let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); - let numeric = crate::routes::resolve::fp_to_numeric_id(from_fp); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); Some(serde_json::json!({ "callback_query": { "id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), "from": { "id": numeric, - "id_str": from_fp, - "is_bot": false, + "is_bot": false, }, "data": bot_msg.get("data").and_then(|v| v.as_str()).unwrap_or(""), "message": bot_msg.get("message"), diff --git a/warzone/crates/warzone-server/src/routes/resolve.rs b/warzone/crates/warzone-server/src/routes/resolve.rs index 8d3f629..f8f057e 100644 --- a/warzone/crates/warzone-server/src/routes/resolve.rs +++ b/warzone/crates/warzone-server/src/routes/resolve.rs @@ -7,7 +7,22 @@ use axum::{ use crate::errors::AppResult; use crate::state::AppState; -/// Convert a fingerprint hex string to a stable i64 ID (for Telegram compatibility). +/// Convert a fingerprint to a per-bot unique numeric ID. +/// Hash(bot_token + user_fp) → i64. Different bots see different IDs for the same user. +/// This prevents cross-bot user correlation (same privacy model as Telegram). +pub fn fp_to_numeric_id_for_bot(fp: &str, bot_token: &str) -> i64 { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(bot_token.as_bytes()); + hasher.update(b":"); + hasher.update(fp.as_bytes()); + let hash = hasher.finalize(); + let mut arr = [0u8; 8]; + arr.copy_from_slice(&hash[..8]); + (i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF) // ensure positive +} + +/// Convert a fingerprint hex string to a stable i64 ID (non-bot contexts). /// Uses first 8 bytes of the fingerprint as a positive i64. pub fn fp_to_numeric_id(fp: &str) -> i64 { let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).take(16).collect(); diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index b8dd28f..813245c 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v12'; +const CACHE = 'wz-v13'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -251,7 +251,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.30'; +const VERSION = '0.0.31'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── diff --git a/warzone/docs/LLM_BOT_DEV.md b/warzone/docs/LLM_BOT_DEV.md index 182dab2..5962a25 100644 --- a/warzone/docs/LLM_BOT_DEV.md +++ b/warzone/docs/LLM_BOT_DEV.md @@ -57,8 +57,8 @@ Response: {"ok":true,"result":[ {"update_id":1,"message":{ "message_id":"uuid", - "from":{"id":123456,"id_str":"sender_fp_hex","is_bot":false}, - "chat":{"id":123456,"id_str":"sender_fp_hex","type":"private"}, + "from":{"id":123456,"is_bot":false}, + "chat":{"id":123456,"type":"private"}, "date":1711612800, "text":"Hello bot!" }} @@ -69,8 +69,8 @@ Response: - `offset` — skip updates < offset (acknowledge processed). **Always use this.** - `timeout` — long-poll seconds (max 50, matches Telegram) - `limit` — max updates (default 100) -- `from.id` — numeric (i64 hash of fingerprint, for TG library compat) -- `from.id_str` — hex fingerprint string +- `from.id` — numeric (per-bot unique hash, different bots see different IDs for same user) +- No raw fingerprint exposed to bots (privacy: bots can't correlate users cross-bot) ### sendMessage ``` @@ -246,7 +246,7 @@ The bridge translates numeric chat_id ↔ fingerprints automatically. | User→bot messages | plaintext | plaintext (auto-detected by client) | | Bot creation | @BotFather chat | @botfather chat (same flow) | | getUpdates timeout | up to 50s | up to 50s | -| from.id | integer | integer (hash of fp) + id_str (hex fp) | +| from.id | integer | integer (per-bot unique hash, no raw fp exposed) | | File upload | multipart | JSON reference (v1) | | Inline keyboards | full | stored + delivered, no popup | | Webhooks | HTTPS POST | HTTP POST (delivered live) | @@ -256,7 +256,7 @@ The bridge translates numeric chat_id ↔ fingerprints automatically. ## Key Rules 1. **Always use offset** in getUpdates — without it you reprocess messages -2. **chat_id** — use `msg.chat.id` (numeric) or `msg.chat.id_str` (hex fingerprint) +2. **chat_id** — use `msg.chat.id` (numeric, per-bot unique) for replies 3. **Bot names** must end with `bot`, `Bot`, or `_bot` 4. **Only @botfather** can create bots — direct API registration requires botfather_token 5. **Server needs --enable-bots** — without it all bot endpoints return 403 diff --git a/warzone/docs/LLM_HELP.md b/warzone/docs/LLM_HELP.md index 70af2e2..e049f97 100644 --- a/warzone/docs/LLM_HELP.md +++ b/warzone/docs/LLM_HELP.md @@ -175,7 +175,7 @@ Bots can optionally participate in E2E encryption by registering with a seed and - Webhooks: updates are delivered live to the registered URL (POST with JSON body) - chat_id: accepts hex fingerprint or numeric ID (TG compatibility) - parse_mode: `HTML` renders basic HTML tags (, , , ) in clients -- from.id is numeric (integer), from.id_str contains the hex fingerprint +- from.id is per-bot unique numeric (bots can't correlate users cross-bot, no raw fingerprint exposed) Update types in getUpdates: - Encrypted msg: text=null, raw_encrypted=base64 From f04c24187d5c1c97794adca4b74e3bffd0a5eefd Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 13:54:19 +0400 Subject: [PATCH 30/50] feat: auto-join #ops on first login (web + TUI) New users with no peer/group set automatically join #ops so they have someone to talk to. Saved peer overrides this on subsequent visits. Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-client/src/tui/mod.rs | 18 ++++++++++++++++++ .../crates/warzone-server/src/routes/web.rs | 13 ++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index 7ec3326..c53a0e2 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -48,6 +48,24 @@ pub async fn run_tui( network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await; }); + // Auto-join #ops if no peer set + if app.peer_fp.is_none() { + let join_url = format!("{}/v1/groups/ops/join", client.base_url); + let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase(); + let _ = client.client.post(&join_url) + .json(&serde_json::json!({"fingerprint": fp_clean})) + .send().await; + app.peer_fp = Some("#ops".to_string()); + app.add_message(types::ChatLine { + sender: "system".into(), + text: "Welcome! You have been added to #ops".into(), + is_system: true, + is_self: false, + message_id: None, + timestamp: chrono::Local::now(), + }); + } + loop { terminal.draw(|frame| app.draw(frame))?; diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 813245c..71827c4 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -925,9 +925,20 @@ async function enterChat() { addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info'); const savedPeer = localStorage.getItem('wz-peer'); - if (savedPeer) $peerInput.value = savedPeer; + if (savedPeer) { + $peerInput.value = savedPeer; + } connectWebSocket(); + + // Auto-join #ops if no peer/group set + if (!savedPeer) { + setTimeout(async () => { + await groupSwitch('ops'); + addSys('Welcome! You have been added to #ops'); + }, 500); + } + $input.focus(); } From 13f2227bf0e3dc6d2cfe8fe04d1a5c1ba7972461 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 14:07:34 +0400 Subject: [PATCH 31/50] =?UTF-8?q?v0.0.32:=20system=20bots=20config=20?= =?UTF-8?q?=E2=80=94=20persist=20across=20data=20wipes,=20welcome=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - --bots-config loads JSON array of system bots on startup - Bots auto-created if missing, aliases restored on every start - Bot list stored in DB for welcome screen (system:bot_list key) - GET /v1/bot/list returns system bots (public, no auth) Welcome screen: - Web + TUI show available bots on first login - "Available bots: @helpbot — featherChat help, @codebot — Coding..." - Clickable in web (via address detection) Config: bots.example.json with 10 suggested bots Usage: warzone-server --enable-bots --bots-config bots.json Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +-- warzone/Cargo.toml | 2 +- warzone/bots.example.json | 12 +++ warzone/crates/warzone-client/src/tui/mod.rs | 17 +++++ warzone/crates/warzone-protocol/Cargo.toml | 2 +- warzone/crates/warzone-server/src/main.rs | 76 +++++++++++++++++++ .../crates/warzone-server/src/routes/bot.rs | 17 +++++ .../crates/warzone-server/src/routes/web.rs | 18 ++++- 8 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 warzone/bots.example.json diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index b751509..58c81e1 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.31" +version = "0.0.32" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.31" +version = "0.0.32" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.31" +version = "0.0.32" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.31" +version = "0.0.32" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.31" +version = "0.0.32" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 43f7bfc..07af4be 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.31" +version = "0.0.32" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/bots.example.json b/warzone/bots.example.json new file mode 100644 index 0000000..f2c3c22 --- /dev/null +++ b/warzone/bots.example.json @@ -0,0 +1,12 @@ +[ + {"name": "helpbot", "description": "featherChat help & FAQ"}, + {"name": "codebot", "description": "Coding assistant"}, + {"name": "survivalbot", "description": "War/emergency/survival guide"}, + {"name": "farsibot", "description": "Farsi → English translation"}, + {"name": "engbot", "description": "English → Farsi translation"}, + {"name": "mathbot", "description": "Math helper"}, + {"name": "medbot", "description": "First aid & health info"}, + {"name": "writebot", "description": "Writing assistant"}, + {"name": "cookbot", "description": "Cooking with limited ingredients"}, + {"name": "mindbot", "description": "Mental health & stress support"} +] diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index c53a0e2..4fe02ac 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -64,6 +64,23 @@ pub async fn run_tui( message_id: None, timestamp: chrono::Local::now(), }); + + // Show system bots + if let Ok(resp) = client.client.get(format!("{}/v1/bot/list", client.base_url)).send().await { + if let Ok(data) = resp.json::().await { + if let Some(bots) = data.get("bots").and_then(|v| v.as_array()) { + if !bots.is_empty() { + app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); + for b in bots { + let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let desc = b.get("description").and_then(|v| v.as_str()).unwrap_or(""); + app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); + } + app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() }); + } + } + } + } } loop { diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 9a744a0..cec0378 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.31" +version = "0.0.32" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index c5d65a9..57ef32e 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -27,6 +27,10 @@ struct Cli { /// Enable bot API (disabled by default) #[arg(long, default_value = "false")] enable_bots: bool, + + /// System bots config file (JSON array). Bots are auto-created on startup. + #[arg(long)] + bots_config: Option, } #[tokio::main] @@ -94,6 +98,78 @@ async fn main() -> anyhow::Result<()> { "last_active": chrono::Utc::now().timestamp(), }); let _ = state.db.aliases.insert(b"rec:botfather", serde_json::to_vec(&bf_record).unwrap_or_default()); + + // Load system bots from config file + if let Some(ref bots_path) = cli.bots_config { + match std::fs::read_to_string(bots_path) { + Ok(data) => { + if let Ok(bots) = serde_json::from_str::>(&data) { + for bot in &bots { + let name = bot.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let desc = bot.get("description").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + + let alias = name.to_lowercase(); + let alias_key = format!("a:{}", alias); + + // Check if already exists + let existing_fp = state.db.aliases.get(alias_key.as_bytes()) + .ok().flatten() + .map(|v| String::from_utf8_lossy(&v).to_string()); + + let fp = if let Some(ref efp) = existing_fp { + // Bot exists — just ensure alias record is intact + efp.clone() + } else { + // Create new bot + let fp_bytes: [u8; 16] = rand::random(); + let fp = hex::encode(fp_bytes); + let token_rand: [u8; 16] = rand::random(); + let token = format!("{}:{}", &fp[..16], hex::encode(token_rand)); + + let bot_info = serde_json::json!({ + "name": name, + "fingerprint": fp, + "token": token, + "owner": "system", + "description": desc, + "system_bot": true, + "e2e": false, + "created_at": chrono::Utc::now().timestamp(), + }); + let _ = state.db.tokens.insert(format!("bot:{}", token).as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default()); + let _ = state.db.tokens.insert(format!("bot_fp:{}", fp).as_bytes(), token.as_bytes()); + let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes()); + let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes()); + tracing::info!("System bot @{} created (token: {}...)", alias, &token[..20]); + fp + }; + + // Always ensure alias record exists + let rec = serde_json::json!({ + "alias": alias, + "fingerprint": fp, + "recovery_key": "", + "registered_at": chrono::Utc::now().timestamp(), + "last_active": chrono::Utc::now().timestamp(), + }); + let _ = state.db.aliases.insert(format!("rec:{}", alias).as_bytes(), serde_json::to_vec(&rec).unwrap_or_default()); + } + tracing::info!("Loaded {} system bots from {}", bots.len(), bots_path); + + // Store bot list in DB for welcome screen + let bot_list: Vec = bots.iter().map(|b| { + serde_json::json!({ + "name": b.get("name").and_then(|v| v.as_str()).unwrap_or(""), + "description": b.get("description").and_then(|v| v.as_str()).unwrap_or(""), + }) + }).collect(); + let _ = state.db.tokens.insert(b"system:bot_list", serde_json::to_vec(&bot_list).unwrap_or_default()); + } + } + Err(e) => tracing::warn!("Failed to load bots config '{}': {}", bots_path, e), + } + } } // Spawn federation outgoing WS connection if enabled diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 0764a84..477288f 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -31,6 +31,7 @@ use crate::state::AppState; /// Build the bot API routes (nested under `/v1`). pub fn routes() -> Router { Router::new() + .route("/bot/list", get(list_system_bots)) .route("/bot/register", post(register_bot)) .route("/bot/:token/getMe", get(get_me)) .route("/bot/:token/getUpdates", post(get_updates)) @@ -43,6 +44,22 @@ pub fn routes() -> Router { .route("/bot/:token/sendDocument", post(send_document)) } +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// System bot list (public, no auth needed) +// --------------------------------------------------------------------------- + +/// GET /v1/bot/list — returns system bots for welcome screen. +async fn list_system_bots( + State(state): State, +) -> Json { + let bots = state.db.tokens.get(b"system:bot_list") + .ok().flatten() + .and_then(|v| serde_json::from_slice::>(&v).ok()) + .unwrap_or_default(); + Json(serde_json::json!({ "ok": true, "bots": bots })) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 71827c4..7947efa 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v13'; +const CACHE = 'wz-v14'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -251,7 +251,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.31'; +const VERSION = '0.0.32'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -924,6 +924,20 @@ async function enterChat() { addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above'); addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info'); + // Show system bots if available + try { + const botResp = await fetch(SERVER + '/v1/bot/list'); + const botData = await botResp.json(); + if (botData.ok && botData.bots && botData.bots.length > 0) { + addSys(''); + addSys('Available bots:'); + for (const b of botData.bots) { + addSys(' @' + b.name + ' — ' + b.description); + } + addSys('Message a bot: /peer @botname'); + } + } catch(e) {} + const savedPeer = localStorage.getItem('wz-peer'); if (savedPeer) { $peerInput.value = savedPeer; From 5415d1f5c8be247edf041d07bb1833449dad3eda Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 14:47:54 +0400 Subject: [PATCH 32/50] fix: auto-join #ops creates group if missing, remove auth from create/join group Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-client/src/tui/mod.rs | 10 +++++++--- warzone/crates/warzone-server/src/routes/groups.rs | 2 -- warzone/crates/warzone-server/src/routes/web.rs | 13 ++++++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index 4fe02ac..2b92b68 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -48,11 +48,15 @@ pub async fn run_tui( network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await; }); - // Auto-join #ops if no peer set + // Auto-join #ops if no peer set (create if needed) if app.peer_fp.is_none() { - let join_url = format!("{}/v1/groups/ops/join", client.base_url); let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase(); - let _ = client.client.post(&join_url) + // Create #ops if it doesn't exist + let _ = client.client.post(format!("{}/v1/groups/create", client.base_url)) + .json(&serde_json::json!({"name": "ops", "creator": fp_clean})) + .send().await; + // Join + let _ = client.client.post(format!("{}/v1/groups/ops/join", client.base_url)) .json(&serde_json::json!({"fingerprint": fp_clean})) .send().await; app.peer_fp = Some("#ops".to_string()); diff --git a/warzone/crates/warzone-server/src/routes/groups.rs b/warzone/crates/warzone-server/src/routes/groups.rs index c9768e9..036728a 100644 --- a/warzone/crates/warzone-server/src/routes/groups.rs +++ b/warzone/crates/warzone-server/src/routes/groups.rs @@ -75,7 +75,6 @@ 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> { @@ -100,7 +99,6 @@ async fn create_group( } async fn join_group( - _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Path(name): Path, Json(req): Json, diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 7947efa..cec5b94 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -945,11 +945,18 @@ async function enterChat() { connectWebSocket(); - // Auto-join #ops if no peer/group set + // Auto-join #ops if no peer/group set (create if needed) if (!savedPeer) { setTimeout(async () => { - await groupSwitch('ops'); - addSys('Welcome! You have been added to #ops'); + try { + // Create #ops if it doesn't exist (ignore error if already exists) + await fetch(SERVER + '/v1/groups/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name:'ops', creator: normFP(myFingerprint)}) }); + // Join (no auth needed for join in current setup) + await fetch(SERVER + '/v1/groups/ops/join', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({fingerprint: normFP(myFingerprint)}) }); + currentGroup = 'ops'; + $peerInput.value = '#ops'; + addSys('Welcome! You have been added to #ops'); + } catch(e) { dbg('Auto-join #ops failed:', e); } }, 500); } From 1e47b888c831ec8d60738f0c4f6d8c9bb03125c4 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 14:51:39 +0400 Subject: [PATCH 33/50] v0.0.33: bump version Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- warzone/crates/warzone-server/src/routes/web.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 07af4be..e9c5a5a 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.32" +version = "0.0.33" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index cec0378..1cfea19 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.32" +version = "0.0.33" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index cec5b94..2b4420a 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v14'; +const CACHE = 'wz-v15'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -251,7 +251,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.32'; +const VERSION = '0.0.33'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── From 3489a7cf74e9de07fed268c846cee1d13953ee8b Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 15:05:11 +0400 Subject: [PATCH 34/50] fix: log full bot tokens + write to data_dir/bot-tokens.txt Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +++++----- warzone/crates/warzone-server/src/main.rs | 24 +++++++++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 58c81e1..20db4ef 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.32" +version = "0.0.33" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.32" +version = "0.0.33" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.32" +version = "0.0.33" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.32" +version = "0.0.33" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.32" +version = "0.0.33" dependencies = [ "base64", "bincode", diff --git a/warzone/crates/warzone-server/src/main.rs b/warzone/crates/warzone-server/src/main.rs index 57ef32e..a42b5c3 100644 --- a/warzone/crates/warzone-server/src/main.rs +++ b/warzone/crates/warzone-server/src/main.rs @@ -82,7 +82,7 @@ async fn main() -> anyhow::Result<()> { // Register alias let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes()); let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather"); - tracing::info!("BotFather created: @botfather (token: {}...)", &token[..20]); + tracing::info!("BotFather created: @botfather (token: {})", token); } else { tracing::info!("BotFather already exists"); } @@ -141,7 +141,7 @@ async fn main() -> anyhow::Result<()> { let _ = state.db.tokens.insert(format!("bot_fp:{}", fp).as_bytes(), token.as_bytes()); let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes()); let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes()); - tracing::info!("System bot @{} created (token: {}...)", alias, &token[..20]); + tracing::info!("System bot @{} created (token: {})", alias, token); fp }; @@ -157,6 +157,26 @@ async fn main() -> anyhow::Result<()> { } tracing::info!("Loaded {} system bots from {}", bots.len(), bots_path); + // Write tokens to file for easy access + let tokens_path = format!("{}/bot-tokens.txt", cli.data_dir); + let mut token_lines = Vec::new(); + for bot in &bots { + let name = bot.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + let alias = name.to_lowercase(); + if let Some(fp_bytes) = state.db.aliases.get(format!("a:{}", alias).as_bytes()).ok().flatten() { + let fp = String::from_utf8_lossy(&fp_bytes).to_string(); + if let Some(tok_bytes) = state.db.tokens.get(format!("bot_fp:{}", fp).as_bytes()).ok().flatten() { + let tok = String::from_utf8_lossy(&tok_bytes).to_string(); + token_lines.push(format!("{}={}", alias.to_uppercase(), tok)); + } + } + } + if !token_lines.is_empty() { + let _ = std::fs::write(&tokens_path, token_lines.join("\n") + "\n"); + tracing::info!("Bot tokens written to {}", tokens_path); + } + // Store bot list in DB for welcome screen let bot_list: Vec = bots.iter().map(|b| { serde_json::json!({ From 7628ff7a753781cffa23bddeae57bb8ded25ba63 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 15:35:52 +0400 Subject: [PATCH 35/50] =?UTF-8?q?v0.0.34:=20fix=20bot=20sendMessage=20?= =?UTF-8?q?=E2=80=94=20store=20per-bot=20numeric=20ID=20reverse=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-bot numeric IDs (privacy feature) broke sendMessage because the reverse lookup couldn't find the fingerprint from the per-bot hash. Fix: store numid: → fingerprint in tokens tree when generating updates. resolve_chat_id checks this mapping first. Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +++--- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/bot.rs | 35 +++++++++++++------ .../crates/warzone-server/src/routes/web.rs | 4 +-- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 20db4ef..af3744a 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.33" +version = "0.0.34" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.33" +version = "0.0.34" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.33" +version = "0.0.34" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.33" +version = "0.0.34" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.33" +version = "0.0.34" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index e9c5a5a..c448f78 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.33" +version = "0.0.34" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 1cfea19..433f9cd 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.33" +version = "0.0.34" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs index 477288f..d5c204d 100644 --- a/warzone/crates/warzone-server/src/routes/bot.rs +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -101,6 +101,12 @@ fn resolve_chat_id(state: &AppState, chat_id: &serde_json::Value) -> Option { let num = n.as_i64().unwrap_or(0); + // Look up per-bot numeric ID reverse mapping + let numid_key = format!("numid:{}", num); + if let Some(fp_bytes) = state.db.tokens.get(numid_key.as_bytes()).ok().flatten() { + return Some(String::from_utf8_lossy(&fp_bytes).to_string()); + } + // Fallback: scan all keys with global hash (legacy) for item in state.db.keys.iter().flatten() { let key_str = String::from_utf8_lossy(&item.0).to_string(); if !key_str.contains(':') && key_str.len() == 32 { @@ -188,9 +194,9 @@ pub async fn try_bot_webhook(state: &AppState, to_fp: &str, message: &[u8]) -> b let update = if let Ok(wire) = bincode::deserialize::(message) { - wire_message_to_update(&wire, message, &token) + wire_message_to_update(state, &wire, message, &token) } else if let Ok(bot_msg) = serde_json::from_slice::(message) { - bot_json_to_update(&bot_msg, &token) + bot_json_to_update(state, &bot_msg, &token) } else { None }; @@ -515,9 +521,9 @@ fn migrate_raw_queue(state: &AppState, bot_fp: &str, bot_token: &str) { let update = if let Ok(wire) = bincode::deserialize::(&value) { - wire_message_to_update(&wire, &value, bot_token) + wire_message_to_update(state, &wire, &value, bot_token) } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { - bot_json_to_update(&bot_msg, bot_token) + bot_json_to_update(state, &bot_msg, bot_token) } else { None }; @@ -533,8 +539,15 @@ fn migrate_raw_queue(state: &AppState, bot_fp: &str, bot_token: &str) { } } +/// Store a per-bot numeric ID → fingerprint reverse mapping. +fn store_numid_mapping(state: &AppState, numeric_id: i64, fingerprint: &str) { + let key = format!("numid:{}", numeric_id); + let _ = state.db.tokens.insert(key.as_bytes(), fingerprint.as_bytes()); +} + /// Convert a `WireMessage` into a Telegram-style update JSON (without update_id). fn wire_message_to_update( + state: &AppState, wire: &warzone_protocol::message::WireMessage, raw_bytes: &[u8], bot_token: &str, @@ -546,7 +559,7 @@ fn wire_message_to_update( .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); - let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, @@ -571,7 +584,7 @@ fn wire_message_to_update( .. } => { let raw_b64 = base64::engine::general_purpose::STANDARD.encode(raw_bytes); - let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, @@ -597,7 +610,7 @@ fn wire_message_to_update( payload, .. } => { - let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, @@ -626,7 +639,7 @@ fn wire_message_to_update( file_size, .. } => { - let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(sender_fingerprint, bot_token); store_numid_mapping(state, numeric, sender_fingerprint); Some(serde_json::json!({ "message": { "message_id": id, @@ -654,12 +667,12 @@ fn wire_message_to_update( } /// Convert a plaintext bot JSON message into a Telegram-style update (without update_id). -fn bot_json_to_update(bot_msg: &serde_json::Value, bot_token: &str) -> Option { +fn bot_json_to_update(state: &AppState, bot_msg: &serde_json::Value, bot_token: &str) -> Option { let msg_type = bot_msg.get("type").and_then(|v| v.as_str())?; match msg_type { "bot_message" => { let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); - let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); store_numid_mapping(state, numeric, from_fp); Some(serde_json::json!({ "message": { "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), @@ -678,7 +691,7 @@ fn bot_json_to_update(bot_msg: &serde_json::Value, bot_token: &str) -> Option { let from_fp = bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""); - let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); + let numeric = crate::routes::resolve::fp_to_numeric_id_for_bot(from_fp, bot_token); store_numid_mapping(state, numeric, from_fp); Some(serde_json::json!({ "callback_query": { "id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 2b4420a..8005709 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v15'; +const CACHE = 'wz-v16'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -251,7 +251,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.33'; +const VERSION = '0.0.34'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── From 0e7277fb20d37e89fecb437c8e6493bd50eb4ed4 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 15:51:43 +0400 Subject: [PATCH 36/50] fix: visible scrollbar on web messages area Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-server/src/routes/web.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 8005709..27b3210 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -154,7 +154,11 @@ const WEB_HTML: &str = r##" #chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px; border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; } - #messages { flex: 1; overflow-y: scroll; padding: 8px 10px; -webkit-overflow-scrolling: touch; min-height: 0; } + #messages { flex: 1; overflow-y: scroll; padding: 8px 10px; -webkit-overflow-scrolling: touch; min-height: 0; max-height: calc(100dvh - 100px); } + #messages::-webkit-scrollbar { width: 8px; } + #messages::-webkit-scrollbar-track { background: #0a0a1a; } + #messages::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } + #messages::-webkit-scrollbar-thumb:hover { background: #555; } .msg { padding: 2px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; } .msg code { background: #1a1a3e; padding: 1px 4px; border-radius: 3px; font-size: 0.95em; color: #4fc3f7; } .msg pre { background: #0d0d20; padding: 8px; border-radius: 4px; margin: 4px 0; overflow-x: auto; border: 1px solid #222; } From 0b58ddcee52881fb300ed08f74d0eff578222cac Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 16:00:43 +0400 Subject: [PATCH 37/50] v0.0.35: WASM create_call_signal, selectable identity, web sections FC-P3-T2: WASM create_call_signal() export - Accepts signal_type string (offer/answer/hangup/etc), payload, target - Returns bincode WireMessage::CallSignal bytes for WS send FC-P3-T9: Selectable identity display in web - ETH address shown in code-style block, click to copy - addSys() gains rawHtml parameter for rich content FC-P3-T5: Section navigation comments in web.rs - 5 section markers: State, Crypto, Network, UI, Commands Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 ++--- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 36 ++++++++++++----- warzone/crates/warzone-wasm/src/lib.rs | 40 +++++++++++++++++++ 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index af3744a..75d31aa 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.34" +version = "0.0.35" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.34" +version = "0.0.35" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.34" +version = "0.0.35" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.34" +version = "0.0.35" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.34" +version = "0.0.35" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index c448f78..ef6f039 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.34" +version = "0.0.35" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 433f9cd..9ebbc55 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.34" +version = "0.0.35" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 27b3210..d2bb8c8 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v16'; +const CACHE = 'wz-v17'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -173,6 +173,8 @@ const WEB_HTML: &str = r##" .msg .ts { color: #333; margin-right: 4px; } .msg .from-self { color: #4ade80; font-weight: bold; } .msg .from-sys { color: #5e9ca0; font-style: italic; } + .identity-code { user-select: all; cursor: pointer; background: #1a1a3e; padding: 2px 6px; border-radius: 3px; color: #4ade80; font-family: monospace; } + .identity-code:hover { background: #252550; } .msg .lock { color: #ff6b9d; } #bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #222; background: #111; @@ -244,7 +246,9 @@ const $messages = document.getElementById('messages'); const $input = document.getElementById('msg-input'); const $peerInput = document.getElementById('peer-input'); -// ── State ── +// ═══════════════════════════════════════════════ +// SECTION: State & Config +// ═══════════════════════════════════════════════ let wasmIdentity = null; // WasmIdentity from WASM let myFingerprint = ''; let myEthAddress = ''; @@ -255,7 +259,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.34'; +const VERSION = '0.0.35'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── @@ -343,7 +347,9 @@ function handleAddrClick(addr) { } } -// ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ── +// ═══════════════════════════════════════════════ +// SECTION: Crypto & Identity +// ═══════════════════════════════════════════════ async function initWasm() { await init('/wasm/warzone_wasm_bg.wasm'); @@ -488,6 +494,10 @@ async function sendEncrypted(peerFP, plaintext) { return msgId; } +// ═══════════════════════════════════════════════ +// SECTION: Network & WebSocket +// ═══════════════════════════════════════════════ + // URL deep links: /message/@alias, /message/0xABC, /group/#ops function handleDeepLink() { const path = window.location.pathname; @@ -739,7 +749,9 @@ try { } } catch(e) {} -// ── UI ── +// ═══════════════════════════════════════════════ +// SECTION: UI & Message Display +// ═══════════════════════════════════════════════ function ts() { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); @@ -871,10 +883,14 @@ function addMsg(from, text, isSelf, messageId, rawHtml) { } } -function addSys(text) { +function addSys(text, rawHtml) { const d = document.createElement('div'); d.className = 'msg'; - d.innerHTML = '' + ts() + ' ' + esc(text) + ''; + if (rawHtml) { + d.innerHTML = '' + ts() + ' ' + text + ''; + } else { + d.innerHTML = '' + ts() + ' ' + esc(text) + ''; + } $messages.appendChild(d); $messages.scrollTop = $messages.scrollHeight; } @@ -922,7 +938,7 @@ async function enterChat() { hdrFp.textContent = (myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)); hdrFp.title = myFingerprint; } - addSys('Identity: ' + (myEthAddress || myFingerprint)); + addSys('Identity: ' + esc(myEthAddress || myFingerprint) + '', true); addSys('Key registered with server'); addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above'); @@ -1074,7 +1090,9 @@ async function sendToGroup(groupName, text) { addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)) + ' [' + groupName + ']', text, true, null); } -// ── Send handler ── +// ═══════════════════════════════════════════════ +// SECTION: Command Handlers +// ═══════════════════════════════════════════════ async function doSend() { const text = $input.value.trim(); diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs index 0c84ae4..396c07a 100644 --- a/warzone/crates/warzone-wasm/src/lib.rs +++ b/warzone/crates/warzone-wasm/src/lib.rs @@ -627,6 +627,46 @@ pub fn create_sender_key_from_distribution( Ok(hex::encode(encoded)) } +/// Create a CallSignal WireMessage for sending via WebSocket. +/// +/// Arguments: +/// - identity: the WasmIdentity of the sender +/// - signal_type: "offer" | "answer" | "ice_candidate" | "hangup" | "reject" | "ringing" | "busy" +/// - payload: SDP offer/answer, ICE candidate JSON, or empty string +/// - target: recipient fingerprint or group name +/// +/// Returns: bincode-serialized WireMessage bytes +#[wasm_bindgen] +pub fn create_call_signal( + identity: &WasmIdentity, + signal_type: &str, + payload: &str, + target: &str, +) -> Result, JsValue> { + use warzone_protocol::message::{CallSignalType, WireMessage}; + + let st = match signal_type.to_lowercase().as_str() { + "offer" => CallSignalType::Offer, + "answer" => CallSignalType::Answer, + "ice_candidate" | "icecandidate" => CallSignalType::IceCandidate, + "hangup" => CallSignalType::Hangup, + "reject" => CallSignalType::Reject, + "ringing" => CallSignalType::Ringing, + "busy" => CallSignalType::Busy, + _ => return Err(JsValue::from_str(&format!("unknown signal type: {}", signal_type))), + }; + + let wire = WireMessage::CallSignal { + id: uuid::Uuid::new_v4().to_string(), + sender_fingerprint: identity.pub_id.fingerprint.to_string(), + signal_type: st, + payload: payload.to_string(), + target: target.to_string(), + }; + + bincode::serialize(&wire).map_err(|e| JsValue::from_str(&format!("serialize: {}", e))) +} + // Tests live in warzone-protocol to avoid js-sys dependency issues. // See warzone-protocol/src/x3dh.rs tests for web-client simulation. From e9182fdb4156befc9dbb10e5ac3c0b1cb1853808 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 16:07:03 +0400 Subject: [PATCH 38/50] =?UTF-8?q?v0.0.36:=20web=20call=20UI=20=E2=80=94=20?= =?UTF-8?q?call/accept/reject/hangup=20with=20signaling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web client: - Call bar between header and messages (hidden when idle) - Call button appears when peer is set (not group) - Incoming call: pulsing notification + Accept/Reject buttons - Call states: idle → calling → ringing → active - /call, /accept, /reject, /hangup slash commands - CallSignal sent via WS binary frames (same as messages) - handleCallSignal processes Offer/Answer/Hangup/Reject/Ringing/Busy - Vibration on incoming call (mobile) - create_call_signal WASM import wired up Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +- warzone/Cargo.toml | 2 +- warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 223 +++++++++++++++++- 4 files changed, 227 insertions(+), 10 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 75d31aa..81b0ffc 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.35" +version = "0.0.36" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.35" +version = "0.0.36" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.35" +version = "0.0.36" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.35" +version = "0.0.36" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.35" +version = "0.0.36" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index ef6f039..a88a123 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.35" +version = "0.0.36" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 9ebbc55..67a3aea 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.35" +version = "0.0.36" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index d2bb8c8..c1afbc2 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v17'; +const CACHE = 'wz-v18'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -189,6 +189,21 @@ const WEB_HTML: &str = r##" .addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; } .addr:hover { color: #81d4fa; } + /* Call UI */ + #call-bar { display: none; padding: 6px 10px; background: #1a0a2e; border-bottom: 1px solid #4a1a5c; + align-items: center; gap: 8px; font-size: 0.85em; } + #call-bar.active { display: flex; } + #call-bar .call-status { flex: 1; color: #ce93d8; } + .call-btn { padding: 4px 12px; border: none; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 0.8em; } + .call-btn-green { background: #2d5016; color: #4ade80; } + .call-btn-green:hover { background: #3d6020; } + .call-btn-red { background: #5c1a1a; color: #ff6b6b; } + .call-btn-red:hover { background: #6c2a2a; } + .call-btn-blue { background: #1a1a5c; color: #4fc3f7; } + .call-btn-blue:hover { background: #2a2a6c; } + .incoming-call { animation: pulse 1.5s infinite; } + @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } + @media (max-width: 500px) { .msg { font-size: 0.8em; } #chat-header input { width: 180px; } @@ -230,6 +245,13 @@ const WEB_HTML: &str = r##"
  • +
    + No active call + + + + +
    @@ -239,7 +261,7 @@ const WEB_HTML: &str = r##"