Merge feature/wzp-call-infrastructure: v0.0.22→v0.0.44

50 commits: call infrastructure, bot API, federation, web/TUI polish.

Key features:
- Voice calls via WZP audio bridge (signaling + audio)
- Telegram-compatible Bot API (BotFather, getUpdates, sendMessage)
- Server-to-server federation (persistent WS, presence sync)
- Web: markdown, call UI, ETH display, inline keyboards
- TUI: markdown, read receipts, call commands, tab completion
- Session versioning, wire envelope format, auto-backup
- 155 tests passing
This commit is contained in:
Siavash Sameni
2026-03-30 08:52:30 +04:00
72 changed files with 14197 additions and 3330 deletions

88
warzone/CLAUDE.md Normal file
View File

@@ -0,0 +1,88 @@
# 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.
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<T>` in server, `anyhow::Result` in client, `ProtocolError` in protocol
- State: `AppState` with `Arc<Mutex<>>` for shared state, `Arc<Database>` 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/<name>.rs`, add `pub fn routes() -> Router<AppState>`, 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:<token>` in tokens tree
- Reverse lookup: `bot_fp:<fingerprint>` → 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` |

242
warzone/Cargo.lock generated
View File

@@ -163,7 +163,7 @@ dependencies = [
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.24.0",
"tower 0.5.3",
"tower-layer",
"tower-service",
@@ -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.59.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]]
@@ -2464,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"
@@ -2475,7 +2618,7 @@ dependencies = [
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite",
"tungstenite 0.24.0",
]
[[package]]
@@ -2497,6 +2640,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",
@@ -2633,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"
@@ -2646,7 +2813,7 @@ dependencies = [
"httparse",
"log",
"native-tls",
"rand",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
@@ -2789,7 +2956,7 @@ dependencies = [
[[package]]
name = "warzone-client"
version = "0.0.21"
version = "0.0.44"
dependencies = [
"anyhow",
"argon2",
@@ -2802,7 +2969,7 @@ dependencies = [
"futures-util",
"hex",
"libc",
"rand",
"rand 0.8.5",
"ratatui",
"reqwest",
"serde",
@@ -2810,7 +2977,7 @@ dependencies = [
"sha2",
"sled",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.24.0",
"tracing",
"tracing-subscriber",
"url",
@@ -2822,7 +2989,7 @@ dependencies = [
[[package]]
name = "warzone-mule"
version = "0.0.21"
version = "0.0.44"
dependencies = [
"anyhow",
"clap",
@@ -2831,7 +2998,7 @@ dependencies = [
[[package]]
name = "warzone-protocol"
version = "0.0.21"
version = "0.0.44"
dependencies = [
"base64",
"bincode",
@@ -2843,7 +3010,7 @@ dependencies = [
"hex",
"hkdf",
"k256",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
@@ -2856,7 +3023,7 @@ dependencies = [
[[package]]
name = "warzone-server"
version = "0.0.21"
version = "0.0.44"
dependencies = [
"anyhow",
"axum",
@@ -2867,12 +3034,16 @@ dependencies = [
"ed25519-dalek",
"futures-util",
"hex",
"rand",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"sha2",
"sled",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.21.0",
"tower 0.4.13",
"tower-http 0.5.2",
"tracing",
@@ -2883,7 +3054,7 @@ dependencies = [
[[package]]
name = "warzone-wasm"
version = "0.0.21"
version = "0.0.44"
dependencies = [
"base64",
"bincode",
@@ -2891,7 +3062,7 @@ dependencies = [
"getrandom 0.2.17",
"hex",
"js-sys",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"uuid",
@@ -3028,6 +3199,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 +3502,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",
]

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "0.0.21"
version = "0.0.44"
edition = "2021"
license = "MIT"
rust-version = "1.75"
@@ -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
@@ -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"] }

165
warzone/README.md Normal file
View File

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

12
warzone/bots.example.json Normal file
View File

@@ -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"}
]

View File

@@ -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(())

View File

@@ -14,6 +14,8 @@ pub struct ServerClient {
struct RegisterRequest {
fingerprint: String,
bundle: Vec<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
eth_address: Option<String>,
}
#[derive(Serialize)]
@@ -43,6 +45,7 @@ impl ServerClient {
&self,
fingerprint: &str,
bundle: &PreKeyBundle,
eth_address: Option<String>,
) -> 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
@@ -109,6 +113,35 @@ impl ServerClient {
Ok(())
}
/// Check how many one-time pre-keys remain on the server.
pub async fn otpk_count(&self, fingerprint: &str) -> Result<u64> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let resp: serde_json::Value = self.client
.get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean))
.send()
.await
.context("failed to check OTPK count")?
.json()
.await
.context("failed to parse OTPK count")?;
Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0))
}
/// Upload additional one-time pre-keys.
pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let otpks: Vec<serde_json::Value> = keys.iter().map(|(id, pubkey)| {
serde_json::json!({"id": id, "public_key": hex::encode(pubkey)})
}).collect();
self.client
.post(format!("{}/v1/keys/replenish", self.base_url))
.json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks}))
.send()
.await
.context("failed to replenish OTPKs")?;
Ok(())
}
/// Poll for messages addressed to us.
pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> {
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();

View File

@@ -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,
})
}
@@ -51,19 +54,28 @@ impl LocalDb {
/// Save a ratchet session for a peer.
pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> {
let key = peer.to_hex();
let data = bincode::serialize(state).context("failed to serialize session")?;
let data = state.serialize_versioned()
.map_err(|e| anyhow::anyhow!("{}", e))?;
self.sessions.insert(key.as_bytes(), data)?;
self.sessions.flush()?;
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<Option<RatchetState>> {
let key = peer.to_hex();
match self.sessions.get(key.as_bytes())? {
Some(data) => {
let state = bincode::deserialize(&data)
.context("failed to deserialize session")?;
let state = RatchetState::deserialize_versioned(&data)
.map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(Some(state))
}
None => Ok(None),
@@ -101,6 +113,22 @@ impl LocalDb {
Ok(())
}
/// Return the next available OTPK ID (one past the highest stored).
pub fn next_otpk_id(&self) -> u32 {
let mut max_id: Option<u32> = None;
for item in self.pre_keys.iter() {
if let Ok((k, _)) = item {
let key_str = String::from_utf8_lossy(&k);
if let Some(id_str) = key_str.strip_prefix("otpk:") {
if let Ok(id) = id_str.parse::<u32>() {
max_id = Some(max_id.map_or(id, |m: u32| m.max(id)));
}
}
}
}
max_id.map_or(0, |m| m + 1)
}
/// Load and remove a one-time pre-key secret.
pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
let key = format!("otpk:{}", id);
@@ -115,6 +143,39 @@ impl LocalDb {
}
}
// ── Sender Keys ──
/// Save a sender key for a (sender, group) pair.
pub fn save_sender_key(
&self,
sender_fp: &str,
group_name: &str,
key: &warzone_protocol::sender_keys::SenderKey,
) -> Result<()> {
let db_key = format!("sk:{}:{}", sender_fp, group_name);
let data = bincode::serialize(key).context("failed to serialize sender key")?;
self.sender_keys.insert(db_key.as_bytes(), data)?;
self.sender_keys.flush()?;
Ok(())
}
/// Load a sender key for a (sender, group) pair.
pub fn load_sender_key(
&self,
sender_fp: &str,
group_name: &str,
) -> Result<Option<warzone_protocol::sender_keys::SenderKey>> {
let db_key = format!("sk:{}:{}", sender_fp, group_name);
match self.sender_keys.get(db_key.as_bytes())? {
Some(data) => {
let key = bincode::deserialize(&data)
.context("failed to deserialize sender key")?;
Ok(Some(key))
}
None => Ok(None),
}
}
// ── Contacts ──
/// Add or update a contact. Called on send/receive.
@@ -228,6 +289,87 @@ impl LocalDb {
}))
}
/// Create an encrypted backup of all session data.
/// Returns the backup file path.
pub fn create_backup(&self, seed: &[u8; 32]) -> Result<std::path::PathBuf> {
use std::io::Write;
let backup_dir = crate::keystore::data_dir().join("backups");
std::fs::create_dir_all(&backup_dir)?;
// Collect all data
let mut data = serde_json::Map::new();
// Sessions
let mut sessions = serde_json::Map::new();
for item in self.sessions.iter() {
if let Ok((key, value)) = item {
let k = String::from_utf8_lossy(&key).to_string();
sessions.insert(k, serde_json::Value::String(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD, &value
)));
}
}
data.insert("sessions".into(), serde_json::Value::Object(sessions));
// Contacts
let mut contacts = serde_json::Map::new();
for item in self.contacts.iter() {
if let Ok((key, value)) = item {
let k = String::from_utf8_lossy(&key).to_string();
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&value) {
contacts.insert(k, v);
}
}
}
data.insert("contacts".into(), serde_json::Value::Object(contacts));
// Sender keys
let mut sender_keys = serde_json::Map::new();
for item in self.sender_keys.iter() {
if let Ok((key, value)) = item {
let k = String::from_utf8_lossy(&key).to_string();
sender_keys.insert(k, serde_json::Value::String(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD, &value
)));
}
}
data.insert("sender_keys".into(), serde_json::Value::Object(sender_keys));
// Serialize and encrypt
let plaintext = serde_json::to_vec(&serde_json::Value::Object(data))?;
let key_bytes = warzone_protocol::crypto::hkdf_derive(seed, b"", b"warzone-backup", 32);
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
let encrypted = warzone_protocol::crypto::aead_encrypt(&key, &plaintext, b"warzone-backup-aad");
// Write to temp file then rename (atomic)
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
let filename = format!("backup_{}.wzbk", timestamp);
let path = backup_dir.join(&filename);
let tmp_path = backup_dir.join(format!(".{}.tmp", filename));
let mut file = std::fs::File::create(&tmp_path)?;
file.write_all(&encrypted)?;
file.sync_all()?;
std::fs::rename(&tmp_path, &path)?;
// Rotate: keep last 3 backups
let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".wzbk"))
.collect();
backups.sort_by_key(|e| e.file_name());
while backups.len() > 3 {
if let Some(old) = backups.first() {
let _ = std::fs::remove_file(old.path());
backups.remove(0);
}
}
Ok(path)
}
/// Import data from JSON backup (merges, doesn't overwrite existing).
pub fn import_all(&self, data: &serde_json::Value) -> Result<usize> {
let mut count = 0;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,506 @@
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 chrono::Local;
use super::types::{App, ReceiptStatus};
/// Simple markdown-to-spans converter for TUI messages.
/// Handles: **bold**, *italic*, `code`, ```code blocks```.
fn md_to_spans<'a>(text: &'a str, base_style: Style) -> Vec<Span<'a>> {
let mut spans = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
// Code: `...`
if remaining.starts_with('`') && !remaining.starts_with("```") {
if let Some(end) = remaining[1..].find('`') {
spans.push(Span::styled(
&remaining[1..1 + end],
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
));
remaining = &remaining[2 + end..];
continue;
}
}
// Bold: **...**
if remaining.starts_with("**") {
if let Some(end) = remaining[2..].find("**") {
spans.push(Span::styled(
&remaining[2..2 + end],
base_style.add_modifier(Modifier::BOLD),
));
remaining = &remaining[4 + end..];
continue;
}
}
// Italic: *...*
if remaining.starts_with('*') && !remaining.starts_with("**") {
if let Some(end) = remaining[1..].find('*') {
spans.push(Span::styled(
&remaining[1..1 + end],
base_style.add_modifier(Modifier::ITALIC),
));
remaining = &remaining[2 + end..];
continue;
}
}
// Plain text until next special char
let next = remaining.find(|c: char| c == '*' || c == '`').unwrap_or(remaining.len());
if next > 0 {
spans.push(Span::styled(&remaining[..next], base_style));
remaining = &remaining[next..];
} else {
// Stuck on a special char that didn't match a pattern — emit it
spans.push(Span::styled(&remaining[..1], base_style));
remaining = &remaining[1..];
}
}
spans
}
impl App {
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
match message_id {
Some(id) => {
let receipts = self.receipts.lock().unwrap();
match receipts.get(id) {
Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read)
Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered)
Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent)
}
}
None => "",
}
}
fn receipt_color(&self, message_id: &Option<String>) -> Color {
match message_id {
Some(id) => {
let receipts = self.receipts.lock().unwrap();
match receipts.get(id) {
Some(ReceiptStatus::Read) => Color::Blue,
Some(ReceiptStatus::Delivered) => Color::White,
Some(ReceiptStatus::Sent) | None => Color::DarkGray,
}
}
None => Color::DarkGray,
}
}
pub fn draw(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(5), // messages
Constraint::Length(3), // input
])
.split(frame.area());
// Header
let peer_str = match (&self.peer_eth, &self.peer_fp) {
(Some(eth), _) => format!("{}...", &eth[..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) // ●
} 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)])
};
// Call indicator
let call_span = match &self.call_state {
Some(info) => {
let label = match info.state {
super::types::CallPhase::Calling => format!(" \u{1f4de} Calling {}...", &info.peer_display[..info.peer_display.len().min(12)]),
super::types::CallPhase::Ringing => format!(" \u{1f4de} Incoming from {}", &info.peer_display[..info.peer_display.len().min(12)]),
super::types::CallPhase::Active => {
let elapsed = Local::now().signed_duration_since(info.started_at);
let mins = elapsed.num_minutes();
let secs = elapsed.num_seconds() % 60;
format!(" \u{1f50a} {}:{:02}", mins, secs)
}
};
let color = match info.state {
super::types::CallPhase::Calling => Color::Yellow,
super::types::CallPhase::Ringing => Color::Magenta,
super::types::CallPhase::Active => Color::Green,
};
Span::styled(label, Style::default().fg(color))
}
None => Span::raw(""),
};
let header = Paragraph::new(Line::from(vec![
Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::styled(identity_display, 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)),
call_span,
]));
frame.render_widget(header, chunks[0]);
// Messages — render markdown for message bodies via tui-markdown
let msgs = self.messages.lock().unwrap();
let items: Vec<ListItem> = msgs
.iter()
.flat_map(|m| {
let base_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);
// Split text into lines, render markdown per line
let text_lines: Vec<&str> = m.text.split('\n').collect();
let mut result_items = Vec::new();
for (i, line_text) in text_lines.iter().enumerate() {
let mut spans = Vec::new();
if i == 0 {
spans.push(Span::styled(timestamp.clone(), Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(prefix.clone(), base_style.add_modifier(Modifier::BOLD)));
} else {
let indent = " ".repeat(timestamp.len() + prefix.len());
spans.push(Span::raw(indent));
}
// Check for code block lines (```)
if line_text.starts_with("```") {
spans.push(Span::styled(*line_text, Style::default().fg(Color::DarkGray)));
} else if line_text.starts_with("# ") {
spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
} else if line_text.starts_with("## ") {
spans.push(Span::styled(&line_text[3..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD)));
} else if line_text.starts_with("> ") {
spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)));
} else if line_text.starts_with("- ") || line_text.starts_with("* ") {
spans.push(Span::styled("", base_style));
spans.extend(md_to_spans(&line_text[2..], base_style));
} else {
spans.extend(md_to_spans(line_text, base_style));
}
// Receipt on last line
if i == text_lines.len() - 1 {
spans.push(Span::styled(receipt_str, Style::default().fg(receipt_color)));
}
result_items.push(ListItem::new(Line::from(spans)));
}
if result_items.is_empty() {
vec![ListItem::new(Line::from(vec![
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
Span::styled(prefix, base_style.add_modifier(Modifier::BOLD)),
]))]
} else {
result_items
}
})
.collect();
// Scroll support: compute the visible window of items
let visible_height = chunks[1].height.saturating_sub(1) as usize; // minus top border
let total = items.len();
let end = total.saturating_sub(self.scroll_offset);
let start = end.saturating_sub(visible_height);
let visible_items = if total == 0 {
vec![]
} else {
items[start..end].to_vec()
};
let messages_widget = List::new(visible_items)
.block(Block::default().borders(Borders::TOP));
frame.render_widget(messages_widget, chunks[1]);
// Input
let input_title = if self.scroll_offset > 0 {
format!(" [{} new \u{2193}] ", self.scroll_offset)
} else {
" message ".to_string()
};
let input_widget = Paragraph::new(self.input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(input_title),
)
.wrap(Wrap { trim: false });
frame.render_widget(input_widget, chunks[2]);
// Cursor
let x = (self.cursor_pos as u16 + 1).min(chunks[2].width - 2);
frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1));
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use chrono::Local;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use super::super::types::{App, ChatLine};
/// Helper: collect the entire terminal buffer into a single String.
fn full_buffer_text(terminal: &Terminal<TestBackend>) -> String {
let buf = terminal.backend().buffer();
(0..buf.area().height)
.flat_map(|y| {
(0..buf.area().width).map(move |x| {
buf.cell((x, y))
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.unwrap_or(' ')
})
})
.collect()
}
/// Helper: check whether the buffer contains `needle`.
fn buffer_contains(terminal: &Terminal<TestBackend>, needle: &str) -> bool {
full_buffer_text(terminal).contains(needle)
}
/// Helper: collect a single row into a String.
fn row_text(terminal: &Terminal<TestBackend>, row: u16) -> String {
let buf = terminal.backend().buffer();
(0..buf.area().width)
.map(|x| {
buf.cell((x, row))
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.unwrap_or(' ')
})
.collect()
}
fn make_app() -> App {
App::new("aabbcc".into(), Some("ddeeff".into()), "localhost:7700".into())
}
fn make_terminal() -> Terminal<TestBackend> {
let backend = TestBackend::new(80, 24);
Terminal::new(backend).expect("terminal creation should succeed")
}
// ----------------------------------------------------------------
// 1. draw_does_not_panic
// ----------------------------------------------------------------
#[test]
fn draw_does_not_panic() {
let app = make_app();
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).expect("draw should not fail");
}
// ----------------------------------------------------------------
// 2. header_contains_fingerprint
// ----------------------------------------------------------------
#[test]
fn header_contains_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.contains("0x"),
"header should contain fingerprint or ETH address, 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,
sender_fp: 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,
sender_fp: 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,
sender_fp: None,
timestamp: Local::now(),
});
}
app.scroll_offset = 10;
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
!buffer_contains(&terminal, "msg-029"),
"the last message should NOT be visible when scroll_offset=10"
);
}
// ----------------------------------------------------------------
// 8. unread_badge_shows_when_scrolled
// ----------------------------------------------------------------
#[test]
fn unread_badge_shows_when_scrolled() {
let mut app = make_app();
app.scroll_offset = 5;
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
buffer_contains(&terminal, "new"),
"buffer should contain 'new' from the unread badge when scrolled"
);
}
// ----------------------------------------------------------------
// 9. no_unread_badge_at_bottom
// ----------------------------------------------------------------
#[test]
fn no_unread_badge_at_bottom() {
let app = make_app();
// scroll_offset is 0 by default
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
buffer_contains(&terminal, "message"),
"buffer should contain the default title 'message' when not scrolled"
);
assert!(
!full_buffer_text(&terminal).contains("new \u{2193}"),
"buffer should NOT contain 'new ↓' when scroll_offset is 0"
);
}
}

View File

@@ -0,0 +1,292 @@
use std::path::PathBuf;
use sha2::{Sha256, Digest};
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::WireMessage;
use warzone_protocol::types::Fingerprint;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{App, ChatLine, normfp, MAX_FILE_SIZE, CHUNK_SIZE};
impl App {
pub async fn handle_file_send(
&mut self,
path_str: &str,
identity: &IdentityKeyPair,
db: &LocalDb,
client: &ServerClient,
) {
let path = PathBuf::from(path_str);
if !path.exists() {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File not found: {}", path_str),
is_system: true, is_self: false, message_id: None, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: None, timestamp: Local::now(),
});
// Get members
let url = format!("{}/v1/groups/{}", client.base_url, group_name);
let group_data = match client.client.get(&url).send().await {
Ok(resp) => match resp.json::<serde_json::Value>().await {
Ok(d) => d,
Err(_) => return,
},
Err(_) => return,
};
let my_fp = normfp(&self.our_fp);
let members: Vec<String> = group_data.get("members")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
for member in &members {
if *member == my_fp { continue; }
// Send file header + chunks to each member via HTTP
let header = WireMessage::FileHeader {
id: file_id.clone(),
sender_fingerprint: self.our_fp.clone(),
filename: filename.clone(),
file_size,
total_chunks,
sha256: sha256.clone(),
};
if let Ok(encoded) = bincode::serialize(&header) {
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
}
for i in 0..total_chunks {
let start = i as usize * CHUNK_SIZE;
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
let chunk_msg = WireMessage::FileChunk {
id: file_id.clone(),
sender_fingerprint: self.our_fp.clone(),
filename: filename.clone(),
chunk_index: i,
total_chunks,
data: file_data[start..end].to_vec(),
};
if let Ok(encoded) = bincode::serialize(&chunk_msg) {
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
}
}
}
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File '{}' sent to group #{}", filename, group_name),
is_system: true, is_self: false, message_id: None, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: None, timestamp: Local::now(),
});
}
}

View File

@@ -0,0 +1,454 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::types::App;
const COMMANDS: &[&str] = &[
"/help", "/info", "/eth", "/seed", "/backup",
"/peer", "/p", "/reply", "/r", "/dm",
"/call", "/accept", "/reject", "/hangup",
"/alias", "/aliases", "/unalias",
"/contacts", "/c", "/history", "/h",
"/friend", "/unfriend",
"/devices", "/kick",
"/g", "/gcreate", "/gjoin", "/glist", "/gleave", "/gkick", "/gmembers",
"/file", "/quit", "/q",
];
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 / Cmd+A (macOS)
KeyCode::Home => { self.cursor_pos = 0; }
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.
// 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) || key.modifiers.contains(KeyModifiers::SUPER) => {
self.cursor_pos = self.input.len();
}
// 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 / 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 / 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);
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);
}
// Tab: complete slash commands
KeyCode::Tab => {
if self.input.starts_with('/') {
let input_lower = self.input.to_lowercase();
let matches: Vec<&&str> = COMMANDS.iter()
.filter(|cmd| cmd.starts_with(&input_lower) && **cmd != input_lower.as_str())
.collect();
if matches.len() == 1 {
// Single match — complete it
self.input = format!("{} ", matches[0]);
self.cursor_pos = self.input.len();
} else if matches.len() > 1 {
// Multiple matches — find common prefix
let first = matches[0];
let common_len = matches.iter().fold(first.len(), |acc, cmd| {
first.chars().zip(cmd.chars()).take_while(|(a, b)| a == b).count().min(acc)
});
if common_len > self.input.len() {
self.input = first[..common_len].to_string();
self.cursor_pos = self.input.len();
}
// TODO: show matches in a status line
}
}
}
// 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);
}
// ── Tab completion tests ────────────────────────────────────────
#[test]
fn tab_completes_unique_command() {
let mut app = app();
type_str(&mut app, "/he");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "/help ");
assert_eq!(app.cursor_pos, 6);
}
#[test]
fn tab_completes_common_prefix_on_ambiguous() {
let mut app = app();
// "/g" matches /g, /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers
// but /g is an exact-length match that is filtered out since it equals input
// Actually /g exactly matches "/g" so it's excluded. Remaining: /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers
// Common prefix is "/g" which is same length as input, so no change
type_str(&mut app, "/gc");
app.handle_key_event(key(KeyCode::Tab));
// /gcreate is the only match starting with /gc
assert_eq!(app.input, "/gcreate ");
}
#[test]
fn tab_does_nothing_without_slash() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "hello");
}
#[test]
fn tab_does_nothing_when_no_match() {
let mut app = app();
type_str(&mut app, "/zzz");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "/zzz");
}
}

View File

@@ -1,3 +1,195 @@
pub mod app;
mod types;
mod draw;
mod commands;
mod file_transfer;
mod input;
mod network;
pub use app::run_tui;
pub use types::App;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode};
use warzone_protocol::identity::{IdentityKeyPair, Seed};
use crate::net::ServerClient;
use crate::storage::LocalDb;
/// Run the TUI event loop.
pub async fn run_tui(
our_fp: String,
peer_fp: Option<String>,
server_url: String,
identity: IdentityKeyPair,
poll_seed: Seed,
db: LocalDb,
) -> Result<()> {
let mut terminal = ratatui::init();
let client = ServerClient::new(&server_url);
let db = Arc::new(db);
let mut app = App::new(our_fp.clone(), peer_fp, server_url);
// Derive a second identity for the poll loop (can't clone IdentityKeyPair)
let poll_identity = poll_seed.derive_identity();
let poll_messages = app.messages.clone();
let poll_receipts = app.receipts.clone();
let poll_pending_files = app.pending_files.clone();
let poll_last_dm = app.last_dm_peer.clone();
let poll_connected = app.connected.clone();
let poll_client = client.clone();
let poll_db = db.clone();
let poll_fp = our_fp.clone();
tokio::spawn(async move {
network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await;
});
// Spawn periodic backup task (every 5 minutes)
{
let backup_db = db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
interval.tick().await;
if let Ok(seed) = crate::keystore::load_seed_raw() {
match backup_db.create_backup(&seed) {
Ok(path) => tracing::debug!("Auto-backup created: {}", path.display()),
Err(e) => tracing::warn!("Auto-backup failed: {}", e),
}
}
}
});
}
// Auto-join #ops if no peer set (create if needed)
if app.peer_fp.is_none() {
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
// 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());
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,
sender_fp: 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::<serde_json::Value>().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, sender_fp: 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, sender_fp: 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, sender_fp: None, timestamp: chrono::Local::now() });
}
}
}
}
}
// Check and replenish OTPKs if running low
{
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
match client.otpk_count(&fp_clean).await {
Ok(count) => {
if count < 3 {
tracing::info!("OTPK supply low ({}), generating more...", count);
let start_id = db.next_otpk_id();
let otpks = warzone_protocol::prekey::generate_one_time_pre_keys(start_id, 10);
let mut new_keys = Vec::new();
for otpk in &otpks {
let _ = db.save_one_time_pre_key(otpk.id, &otpk.secret);
new_keys.push((otpk.id, *otpk.public.as_bytes()));
}
match client.replenish_otpks(&fp_clean, new_keys).await {
Ok(_) => {
app.add_message(types::ChatLine {
sender: "system".into(),
text: format!("Replenished OTPKs ({} -> {})", count, count + 10),
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: chrono::Local::now(),
});
}
Err(e) => tracing::warn!("Failed to replenish OTPKs: {}", e),
}
}
}
Err(e) => tracing::debug!("Could not check OTPK count: {}", e),
}
}
loop {
terminal.draw(|frame| app.draw(frame))?;
// Send Read receipts for visible messages
{
let msgs = app.messages.lock().unwrap();
let total = msgs.len();
let visible_end = total.saturating_sub(app.scroll_offset);
let visible_height = 20; // approximate
let visible_start = visible_end.saturating_sub(visible_height);
let mut sent = app.read_receipts_sent.lock().unwrap();
for msg in &msgs[visible_start..visible_end] {
if msg.is_system || msg.is_self { continue; }
if let (Some(ref msg_id), Some(ref sfp)) = (&msg.message_id, &msg.sender_fp) {
if sent.contains(msg_id) { continue; }
sent.insert(msg_id.clone());
// Fire-and-forget Read receipt
let receipt = warzone_protocol::message::WireMessage::Receipt {
sender_fingerprint: app.our_fp.clone(),
message_id: msg_id.clone(),
receipt_type: warzone_protocol::message::ReceiptType::Read,
};
if let Ok(encoded) = bincode::serialize(&receipt) {
let client = client.clone();
let to = sfp.clone();
let from = app.our_fp.clone();
tokio::spawn(async move {
let _ = client.send_message(&to, Some(&from), &encoded).await;
});
}
}
}
}
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Enter {
app.handle_send(&identity, &db, &client).await;
app.scroll_offset = 0;
} else {
app.handle_key_event(key);
}
}
}
if app.should_quit {
break;
}
}
ratatui::restore();
Ok(())
}

View File

@@ -0,0 +1,707 @@
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;
});
}
/// ETH address cache: fingerprint → ETH address (populated async, read sync).
pub type EthCache = Arc<std::sync::Mutex<HashMap<String, String>>>;
/// 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[..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::<serde_json::Value>().await {
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
cache.lock().unwrap().insert(fp, eth.to_string());
}
}
}
});
}
/// Pre-populate the ETH cache for all known contacts.
pub async fn prefill_eth_cache(
db: &crate::storage::LocalDb,
client: &ServerClient,
eth_cache: &EthCache,
) {
if let Ok(contacts) = db.list_contacts() {
for c in &contacts {
if let Some(fp) = c.get("fingerprint").and_then(|v| v.as_str()) {
let fp = fp.to_string();
if eth_cache.lock().unwrap().contains_key(&fp) { continue; }
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::<serde_json::Value>().await {
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
eth_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);
}
/// Process a single incoming raw message (shared by WS and HTTP paths).
pub fn process_incoming(
raw: &[u8],
identity: &IdentityKeyPair,
db: &LocalDb,
messages: &Arc<Mutex<Vec<ChatLine>>>,
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: &str,
client: &ServerClient,
eth_cache: &EthCache,
last_dm_peer: &Arc<Mutex<Option<String>>>,
) {
match warzone_protocol::message::deserialize_envelope(raw) {
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer, eth_cache),
Err(_) => {}
}
}
fn process_wire_message(
wire: WireMessage,
identity: &IdentityKeyPair,
db: &LocalDb,
messages: &Arc<Mutex<Vec<ChatLine>>>,
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: &str,
client: &ServerClient,
last_dm_peer: &Arc<Mutex<Option<String>>>,
eth_cache: &EthCache,
) {
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);
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: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) },
text,
is_system: false,
is_self: false,
message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), 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, sender_fp: 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);
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: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) },
text,
is_system: false,
is_self: false,
message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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, sender_fp: 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,
sender_fp: 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,
sender_fp: 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,
sender_fp: 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,
sender_fp: None,
timestamp: Local::now(),
});
}
WireMessage::CallSignal {
id: _,
sender_fingerprint,
signal_type,
payload: _,
target: _,
} => {
use warzone_protocol::message::CallSignalType;
let sender_short = { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) };
match signal_type {
CallSignalType::Offer => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("\u{1f4de} Incoming call from {} \u{2014} /accept or /reject", sender_short),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Terminal bell for incoming call
print!("\x07");
}
CallSignalType::Answer => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("\u{2713} {} accepted the call", sender_short),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Hangup => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Call ended".into(),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Reject => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("{} rejected the call", sender_short),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Ringing => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Ringing...".into(),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Busy => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("{} is busy", sender_short),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
_ => {
messages.lock().unwrap().push(ChatLine {
sender: sender_short,
text: format!("\u{1f4de} Call signal: {:?}", signal_type),
is_system: false,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}
}
}
}
/// Real-time message loop via WebSocket (falls back to HTTP polling).
pub async fn poll_loop(
messages: Arc<Mutex<Vec<ChatLine>>>,
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: String,
identity: IdentityKeyPair,
db: Arc<LocalDb>,
client: ServerClient,
last_dm_peer: Arc<Mutex<Option<String>>>,
connected: Arc<AtomicBool>,
) {
let fp = normfp(&our_fp);
let eth_cache: EthCache = Arc::new(std::sync::Mutex::new(HashMap::new()));
// Pre-populate ETH cache for known contacts
prefill_eth_cache(&db, &client, &eth_cache).await;
// 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, sender_fp: None, timestamp: Local::now(),
});
use futures_util::StreamExt;
let (_, mut read) = ws_stream.split();
while let Some(Ok(msg)) = read.next().await {
match msg {
tokio_tungstenite::tungstenite::Message::Binary(data) => {
process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &eth_cache, &last_dm_peer);
}
tokio_tungstenite::tungstenite::Message::Text(text) => {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
if json.get("type").and_then(|v| v.as_str()) == Some("missed_call") {
let data = json.get("data").cloned().unwrap_or_default();
let caller = data.get("caller_fp").and_then(|v| v.as_str()).unwrap_or("unknown");
let ts = data.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
let when = chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.with_timezone(&Local).format("%H:%M").to_string())
.unwrap_or_else(|| "?".to_string());
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("\u{1f4de} Missed call from {} at {}", &caller[..caller.len().min(12)], when),
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
print!("\x07");
} else if json.get("type").and_then(|v| v.as_str()) == Some("bot_message") {
let from = json.get("from_name").or(json.get("from")).and_then(|v| v.as_str()).unwrap_or("bot");
let text_content = json.get("text").and_then(|v| v.as_str()).unwrap_or("");
messages.lock().unwrap().push(ChatLine {
sender: format!("@{}", from),
text: text_content.to_string(),
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
print!("\x07");
}
}
}
_ => {}
}
}
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, sender_fp: 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, &eth_cache, &last_dm_peer);
}
}
}
}
}

View File

@@ -0,0 +1,267 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Local};
/// Maximum file size: 10 MB.
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
/// Chunk size: 64 KB.
pub const CHUNK_SIZE: usize = 64 * 1024;
/// State for tracking an incoming chunked file transfer.
#[derive(Clone)]
pub struct PendingFileTransfer {
pub filename: String,
pub total_chunks: u32,
pub received: u32,
pub chunks: Vec<Option<Vec<u8>>>,
pub sha256: String,
pub file_size: u64,
}
/// Receipt status for a sent message.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReceiptStatus {
Sent,
Delivered,
Read,
}
/// Active call information.
#[derive(Clone)]
pub struct CallInfo {
pub peer_fp: String,
pub peer_display: String,
pub state: CallPhase,
pub started_at: DateTime<Local>,
}
#[derive(Clone, PartialEq)]
pub enum CallPhase {
Calling, // we initiated, waiting for answer
Ringing, // incoming call, waiting for user to accept/reject
Active, // call connected
}
pub struct App {
pub input: String,
pub messages: Arc<Mutex<Vec<ChatLine>>>,
pub our_fp: String,
pub peer_fp: Option<String>,
pub server_url: String,
pub should_quit: bool,
pub cursor_pos: usize,
pub last_dm_peer: Arc<Mutex<Option<String>>>,
/// Track receipt status for messages we sent, keyed by message ID.
pub receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
/// Pending incoming file transfers, keyed by file ID.
pub pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
/// Our ETH address (derived from seed).
pub our_eth: String,
/// Current peer's ETH address (resolved on /peer set).
pub peer_eth: Option<String>,
/// Scroll offset from bottom (0 = pinned to newest).
pub scroll_offset: usize,
/// Whether the WebSocket connection is active.
pub connected: Arc<AtomicBool>,
/// Current call state: None=idle, Some(state)=active
pub call_state: Option<CallInfo>,
/// Message IDs for which we've already sent a Read receipt (avoid duplicates).
pub read_receipts_sent: Arc<Mutex<std::collections::HashSet<String>>>,
}
#[derive(Clone)]
pub struct ChatLine {
pub sender: String,
pub text: String,
pub is_system: bool,
pub is_self: bool,
/// Message ID (for sent messages, used to track receipts).
pub message_id: Option<String>,
/// Sender's full fingerprint (for sending read receipts back).
pub sender_fp: Option<String>,
/// When this message was created/received.
pub timestamp: DateTime<Local>,
}
impl App {
pub fn new(our_fp: String, peer_fp: Option<String>, server_url: String) -> Self {
// 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 {}", identity_display),
is_system: true,
is_self: false,
message_id: None,
sender_fp: 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,
sender_fp: None,
timestamp: Local::now(),
});
} else {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
is_system: true,
is_self: false,
message_id: None,
sender_fp: 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,
sender_fp: 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())),
our_eth,
peer_eth: None,
scroll_offset: 0,
connected: Arc::new(AtomicBool::new(false)),
call_state: None,
read_receipts_sent: Arc::new(Mutex::new(std::collections::HashSet::new())),
}
}
pub fn add_message(&self, line: ChatLine) {
self.messages.lock().unwrap().push(line);
}
}
pub fn normfp(fp: &str) -> String {
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
#[test]
fn app_new_initializes_scroll_offset_to_zero() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn app_new_initializes_connected_to_false() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert!(!app.connected.load(Ordering::Relaxed));
}
#[test]
fn app_new_creates_system_messages() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
let msgs = app.messages.lock().unwrap();
assert!(msgs.len() >= 2);
assert!(msgs[0].is_system);
// First message shows ETH address (if seed exists) or fingerprint
assert!(msgs[0].text.contains("You are"));
}
#[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,
sender_fp: 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,
sender_fp: None,
timestamp: Local::now(),
});
let new_count = app.messages.lock().unwrap().len();
assert_eq!(new_count, initial_count + 1);
}
#[test]
fn normfp_strips_non_hex_and_lowercases() {
assert_eq!(normfp("AA-BB-CC"), "aabbcc");
assert_eq!(normfp("0x1234ABCD"), "01234abcd");
assert_eq!(normfp("hello"), "e"); // only 'e' is hex
assert_eq!(normfp("AABB"), "aabb");
}
#[test]
fn app_new_cursor_pos_zero() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert_eq!(app.cursor_pos, 0);
assert!(app.input.is_empty());
}
#[test]
fn app_new_should_quit_false() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert!(!app.should_quit);
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "warzone-protocol"
version = "0.0.21"
version = "0.0.44"
edition = "2021"
license = "MIT"
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"

View File

@@ -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<String>,
/// 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<Friend>,
}
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<u8> {
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<Self, crate::errors::ProtocolError> {
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");
}
}

View File

@@ -12,3 +12,4 @@ pub mod store;
pub mod history;
pub mod sender_keys;
pub mod ethereum;
pub mod friends;

View File

@@ -43,7 +43,7 @@ pub enum ReceiptType {
/// Wire message format for transport between clients.
/// Used by both CLI and WASM — MUST be identical for interop.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum WireMessage {
/// First message to a peer: X3DH key exchange + first ratchet message.
KeyExchange {
@@ -132,3 +132,104 @@ pub enum CallSignalType {
/// Peer is busy.
Busy,
}
/// Current wire protocol version.
pub const WIRE_VERSION: u8 = 1;
/// Magic bytes to identify versioned envelope: "WZ"
pub const WIRE_MAGIC: [u8; 2] = [0x57, 0x5A];
/// Serialize a WireMessage with version envelope.
/// Format: [0x57][0x5A][version: u8][length: u32 BE][bincode payload]
pub fn serialize_envelope(msg: &WireMessage) -> Result<Vec<u8>, String> {
let payload =
bincode::serialize(msg).map_err(|e| format!("serialize: {}", e))?;
let len = payload.len() as u32;
let mut out = Vec::with_capacity(7 + payload.len());
out.extend_from_slice(&WIRE_MAGIC);
out.push(WIRE_VERSION);
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(&payload);
Ok(out)
}
/// Deserialize a WireMessage, handling both envelope and legacy formats.
/// - Envelope: [0x57][0x5A][version][length][payload]
/// - Legacy: raw bincode (no envelope)
pub fn deserialize_envelope(data: &[u8]) -> Result<WireMessage, String> {
if data.len() >= 7 && data[0] == WIRE_MAGIC[0] && data[1] == WIRE_MAGIC[1] {
let version = data[2];
let len =
u32::from_be_bytes([data[3], data[4], data[5], data[6]]) as usize;
if version > WIRE_VERSION {
return Err(format!(
"unsupported wire version {} (max {}). Please update your client.",
version, WIRE_VERSION
));
}
if data.len() < 7 + len {
return Err("truncated envelope".to_string());
}
bincode::deserialize(&data[7..7 + len])
.map_err(|e| format!("v{} deserialize: {}", version, e))
} else {
// Legacy: raw bincode
bincode::deserialize(data)
.map_err(|e| format!("legacy deserialize: {}", e))
}
}
#[cfg(test)]
mod envelope_tests {
use super::*;
#[test]
fn envelope_roundtrip() {
let msg = WireMessage::Receipt {
sender_fingerprint: "abc123".to_string(),
message_id: "msg-001".to_string(),
receipt_type: ReceiptType::Delivered,
};
let envelope = serialize_envelope(&msg).unwrap();
assert_eq!(&envelope[..2], &WIRE_MAGIC);
assert_eq!(envelope[2], WIRE_VERSION);
let decoded = deserialize_envelope(&envelope).unwrap();
match decoded {
WireMessage::Receipt { message_id, .. } => {
assert_eq!(message_id, "msg-001")
}
_ => panic!("wrong variant"),
}
}
#[test]
fn legacy_still_works() {
let msg = WireMessage::Receipt {
sender_fingerprint: "abc123".to_string(),
message_id: "msg-002".to_string(),
receipt_type: ReceiptType::Read,
};
let raw = bincode::serialize(&msg).unwrap();
let decoded = deserialize_envelope(&raw).unwrap();
match decoded {
WireMessage::Receipt { message_id, .. } => {
assert_eq!(message_id, "msg-002")
}
_ => panic!("wrong variant"),
}
}
#[test]
fn future_version_rejected() {
let mut envelope = serialize_envelope(&WireMessage::Receipt {
sender_fingerprint: "x".into(),
message_id: "y".into(),
receipt_type: ReceiptType::Delivered,
})
.unwrap();
envelope[2] = 99; // fake future version
let result = deserialize_envelope(&envelope);
assert!(result.is_err());
assert!(result.unwrap_err().contains("unsupported wire version"));
}
}

View File

@@ -11,15 +11,20 @@ use crate::errors::ProtocolError;
const MAX_SKIP: u32 = 1000;
/// Current serialization version for [`RatchetState`].
const RATCHET_VERSION: u8 = 1;
/// Magic byte to distinguish versioned from unversioned (legacy) data.
const RATCHET_MAGIC: u8 = 0xFC;
/// A message produced by the ratchet.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RatchetMessage {
pub header: RatchetHeader,
pub ciphertext: Vec<u8>,
}
/// Header included with each ratchet message.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RatchetHeader {
/// Current DH ratchet public key.
pub dh_public: [u8; 32],
@@ -208,6 +213,37 @@ impl RatchetState {
Ok(())
}
/// Serialize with version prefix: `[MAGIC][VERSION][bincode data]`.
///
/// Use [`deserialize_versioned`](Self::deserialize_versioned) to restore.
pub fn serialize_versioned(&self) -> Result<Vec<u8>, String> {
let data = bincode::serialize(self)
.map_err(|e| format!("serialize: {}", e))?;
let mut out = Vec::with_capacity(2 + data.len());
out.push(RATCHET_MAGIC);
out.push(RATCHET_VERSION);
out.extend_from_slice(&data);
Ok(out)
}
/// Deserialize with version awareness. Handles:
/// - Versioned format: `[0xFC][version][bincode]`
/// - Legacy format: raw bincode (no prefix)
pub fn deserialize_versioned(data: &[u8]) -> Result<Self, String> {
if data.len() >= 2 && data[0] == RATCHET_MAGIC {
let version = data[1];
match version {
1 => bincode::deserialize(&data[2..])
.map_err(|e| format!("v1 deserialize: {}", e)),
_ => Err(format!("unknown ratchet version: {}", version)),
}
} else {
// Legacy: try raw bincode (pre-versioning data)
bincode::deserialize(data)
.map_err(|e| format!("legacy deserialize: {}", e))
}
}
fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> {
let their_pub = self
.dh_remote
@@ -312,6 +348,35 @@ mod tests {
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
}
#[test]
fn versioned_serialize_roundtrip() {
let (mut alice, mut bob) = make_pair();
let msg = alice.encrypt(b"test versioning").unwrap();
// Save alice with versioned format
let serialized = alice.serialize_versioned().unwrap();
assert_eq!(serialized[0], 0xFC); // magic byte
assert_eq!(serialized[1], 1); // version 1
// Restore and use
let mut restored = RatchetState::deserialize_versioned(&serialized).unwrap();
let msg2 = restored.encrypt(b"after restore").unwrap();
let plain = bob.decrypt(&msg).unwrap();
assert_eq!(plain, b"test versioning");
let plain2 = bob.decrypt(&msg2).unwrap();
assert_eq!(plain2, b"after restore");
}
#[test]
fn legacy_deserialize_works() {
let (alice, _) = make_pair();
// Serialize with raw bincode (legacy format)
let legacy = bincode::serialize(&alice).unwrap();
// Should still deserialize with versioned reader
let restored = RatchetState::deserialize_versioned(&legacy).unwrap();
assert_eq!(bincode::serialize(&restored).unwrap(), legacy);
}
#[test]
fn many_messages() {
let (mut alice, mut bob) = make_pair();

View File

@@ -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");
}
}

View File

@@ -25,3 +25,10 @@ rand.workspace = true
futures-util = "0.3"
ed25519-dalek.workspace = true
bincode.workspace = true
sha2.workspace = true
reqwest = { workspace = true, features = ["rustls-tls", "json"] }
tokio-tungstenite.workspace = true
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }

View File

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

View File

@@ -0,0 +1,282 @@
//! 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 = "00000000000000000b0ffa00e000000f";
/// 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 <name> - Delete a bot\n\
/token <name> - 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 <name>
let name = text.strip_prefix("/newbot").unwrap_or("").trim();
if name.is_empty() {
return "Usage: /newbot <botname>\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 (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 {}",
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 <botname>".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::<serde_json::Value>(&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::<serde_json::Value>(&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 <name> 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 <botname>".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::<serde_json::Value>(&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);
}
}

View File

@@ -6,6 +6,10 @@ pub struct Database {
pub groups: sled::Tree,
pub aliases: sled::Tree,
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,
}
@@ -17,12 +21,20 @@ 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")?;
let friends = db.open_tree("friends")?;
let eth_addresses = db.open_tree("eth_addresses")?;
Ok(Database {
keys,
messages,
groups,
aliases,
tokens,
calls,
missed_calls,
friends,
eth_addresses,
_db: db,
})
}

View File

@@ -0,0 +1,340 @@
//! Federation: two-server message relay via persistent WebSocket.
//!
//! 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;
/// Federation configuration loaded from JSON.
#[derive(Clone, Debug, serde::Deserialize)]
pub struct FederationConfig {
pub server_id: String,
pub shared_secret: String,
pub peer: PeerConfig,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct PeerConfig {
pub id: String,
pub url: String,
}
/// Load federation config from a JSON file.
pub fn load_config(path: &str) -> anyhow::Result<FederationConfig> {
let data = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("failed to read federation config '{}': {}", path, e))?;
let config: FederationConfig = serde_json::from_str(&data)
.map_err(|e| anyhow::anyhow!("invalid federation config: {}", e))?;
Ok(config)
}
/// Remote presence: which fingerprints are on the peer server.
#[derive(Clone, Debug)]
pub struct RemotePresence {
pub peer_id: String,
pub fingerprints: HashSet<String>,
pub last_updated: i64,
pub connected: bool,
}
impl RemotePresence {
pub fn new(peer_id: String) -> Self {
RemotePresence {
peer_id,
fingerprints: HashSet::new(),
last_updated: 0,
connected: false,
}
}
pub fn contains(&self, fp: &str) -> bool {
self.connected && self.fingerprints.contains(fp)
}
}
/// Sender for outgoing federation messages over the WS.
pub type FederationSender = Arc<Mutex<Option<tokio::sync::mpsc::UnboundedSender<String>>>>;
/// Handle for communicating with the federation peer.
#[derive(Clone)]
pub struct FederationHandle {
pub config: FederationConfig,
pub remote_presence: Arc<Mutex<RemotePresence>>,
/// 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 {
pub fn new(config: FederationConfig) -> Self {
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,
}
}
/// 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.contains(fp)
}
/// Forward a message to the peer server via the persistent WS.
pub async fn forward_message(&self, to_fp: &str, message: &[u8]) -> bool {
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,
});
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<Vec<u8>> {
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<String> {
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<String>) -> bool {
let msg = serde_json::json!({
"type": "presence",
"server_id": self.config.server_id,
"fingerprints": fingerprints,
});
self.send_json(msg).await
}
/// 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: connect to peer's WS endpoint, send auth, then loop.
/// Handles reconnection on failure.
pub async fn outgoing_ws_loop(
handle: FederationHandle,
state: crate::state::AppState,
) {
let ws_url = handle.config.peer.url
.replace("http://", "ws://")
.replace("https://", "wss://");
let ws_url = format!("{}/v1/federation/ws", ws_url);
loop {
tracing::info!("Federation: connecting to peer {} at {}", handle.config.peer.id, ws_url);
match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws_stream, _)) => {
tracing::info!("Federation: connected to peer {}", handle.config.peer.id);
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::<String>();
{
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<String> = {
let conns = state.connections.lock().await;
conns.keys().cloned().collect()
};
let _ = handle.push_presence(fps).await;
// Spawn task to forward outgoing channel + periodic ping to WS
let send_task = tokio::spawn(async move {
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<String> = {
let conns = presence_conns.lock().await;
conns.keys().cloned().collect()
};
if !presence_handle.push_presence(fps).await {
break;
}
}
});
// Read incoming messages from peer
while let Some(Ok(msg)) = ws_rx.next().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;
}
{
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(std::time::Duration::from_secs(3)).await;
}
}
/// 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<String> = parsed.get("fingerprints")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("?");
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);
}
}
}

View File

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

View File

@@ -1,8 +1,11 @@
use clap::Parser;
mod botfather;
pub mod auth_middleware;
mod config;
mod db;
mod errors;
mod federation;
mod routes;
mod state;
@@ -16,6 +19,18 @@ 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<String>,
/// 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<String>,
}
#[tokio::main]
@@ -30,11 +45,204 @@ 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)?;
// Reload active calls from DB
{
let now = chrono::Utc::now().timestamp();
let mut loaded = 0u32;
let mut expired = 0u32;
for item in state.db.calls.iter().flatten() {
if let Ok(call) = serde_json::from_slice::<state::CallState>(&item.1) {
match call.status {
state::CallStatus::Ringing | state::CallStatus::Active => {
if now - call.created_at > 86400 {
let mut ended = call.clone();
ended.status = state::CallStatus::Ended;
ended.ended_at = Some(now);
let _ = state.db.calls.insert(
&item.0,
serde_json::to_vec(&ended).unwrap_or_default(),
);
expired += 1;
} else {
state.active_calls.lock().await.insert(call.call_id.clone(), call);
loaded += 1;
}
}
_ => {} // Ended calls stay in DB but not in memory
}
}
}
if loaded > 0 || expired > 0 {
tracing::info!("Calls: loaded {} active, expired {} stale", loaded, expired);
}
}
// 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);
}
// 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 = "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]>()));
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);
} 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());
// 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::<Vec<serde_json::Value>>(&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);
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);
// 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<serde_json::Value> = 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
if let Some(ref fed) = state.federation {
let handle = fed.clone();
let fed_state = state.clone();
tokio::spawn(async move {
federation::outgoing_ws_loop(handle, fed_state).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);

View File

@@ -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<AppState>,
Json(req): Json<RegisterRequest>,
) -> AppResult<Json<serde_json::Value>> {
@@ -122,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 {
@@ -151,6 +164,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();
@@ -190,6 +210,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<AppState>,
Json(req): Json<RecoverRequest>,
) -> AppResult<Json<serde_json::Value>> {
@@ -244,6 +265,7 @@ struct RenewRequest {
/// Renew/heartbeat — resets the TTL. Called automatically on activity.
async fn renew_alias(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<RenewRequest>,
) -> AppResult<Json<serde_json::Value>> {
@@ -289,7 +311,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" })))
}
}
}
@@ -347,6 +382,7 @@ struct UnregisterRequest {
/// Remove your own alias.
async fn unregister_alias(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<UnregisterRequest>,
) -> AppResult<Json<serde_json::Value>> {
@@ -381,6 +417,7 @@ struct AdminRemoveRequest {
/// Admin: remove any alias.
async fn admin_remove_alias(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<AdminRemoveRequest>,
) -> AppResult<Json<serde_json::Value>> {

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
//! Federation route handlers: WS endpoint for peer servers + status.
use axum::{
extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}},
response::IntoResponse,
routing::get,
Json, Router,
};
use futures_util::{SinkExt, StreamExt};
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/federation/ws", get(federation_ws_handler))
.route("/federation/status", get(federation_status))
}
/// WebSocket endpoint for incoming peer server connections.
async fn federation_ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_peer_ws(socket, state))
}
/// 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;
}
};
// Wait for auth message (5 second timeout)
let auth_msg = tokio::time::timeout(
std::time::Duration::from_secs(5),
ws_rx.next(),
).await;
let peer_id = match auth_msg {
Ok(Some(Ok(Message::Text(text)))) => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&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<String> = parsed.get("fingerprints")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let 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<String> = {
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;
rp.connected = false;
rp.fingerprints.clear();
}
tracing::info!("Federation WS: peer {} disconnected", peer_id);
}
/// Federation health status.
async fn federation_status(
State(state): State<AppState>,
) -> Json<serde_json::Value> {
match state.federation {
Some(ref federation) => {
let rp = federation.remote_presence.lock().await;
Json(serde_json::json!({
"enabled": true,
"server_id": federation.config.server_id,
"peer_id": federation.config.peer.id,
"peer_url": federation.config.peer.url,
"peer_connected": rp.connected,
"remote_clients": rp.fingerprints.len(),
"last_sync": rp.last_updated,
}))
}
None => {
Json(serde_json::json!({ "enabled": false }))
}
}
}

View File

@@ -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<AppState> {
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<AppState>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>,
Json(req): Json<SaveFriendsRequest>,
) -> AppResult<Json<serde_json::Value>> {
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 })))
}

View File

@@ -169,6 +169,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<AppState>,
Path(name): Path<String>,
Json(req): Json<GroupSendRequest>,
@@ -210,6 +211,7 @@ async fn send_to_group(
}
async fn leave_group(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<JoinRequest>,
@@ -235,6 +237,7 @@ struct KickRequest {
}
async fn kick_member(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<KickRequest>,
@@ -276,16 +279,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<serde_json::Value> = 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,
}));
}
@@ -293,5 +302,6 @@ async fn get_members(
"name": group.name,
"members": members_info,
"count": members_info.len(),
"online_count": online_count,
})))
}

View File

@@ -46,6 +46,8 @@ struct RegisterRequest {
#[serde(default)]
device_id: Option<String>,
bundle: Vec<u8>,
#[serde(default)]
eth_address: Option<String>,
}
#[derive(Serialize)]
@@ -54,6 +56,7 @@ struct RegisterResponse {
}
async fn register_keys(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Json<RegisterResponse> {
@@ -67,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 })
}
@@ -84,9 +97,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),
@@ -129,6 +159,7 @@ struct OtpkEntry {
/// Upload additional one-time pre-keys.
async fn replenish_otpks(
State(state): State<AppState>,
Json(req): Json<ReplenishRequest>,
) -> Json<serde_json::Value> {

View File

@@ -9,9 +9,9 @@ use warzone_protocol::message::WireMessage;
use crate::errors::AppResult;
use crate::state::AppState;
/// Try to extract the message ID from raw bincode-serialized WireMessage bytes.
/// Try to extract the message ID from raw WireMessage bytes (envelope or legacy).
fn extract_message_id(data: &[u8]) -> Option<String> {
if let Ok(wire) = bincode::deserialize::<WireMessage>(data) {
if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) {
match wire {
WireMessage::KeyExchange { id, .. } => Some(id),
WireMessage::Message { id, .. } => Some(id),
@@ -71,6 +71,7 @@ fn normalize_fp(fp: &str) -> String {
}
async fn send_message(
State(state): State<AppState>,
Json(req): Json<SendRequest>,
) -> AppResult<Json<serde_json::Value>> {
@@ -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)

View File

@@ -1,11 +1,19 @@
mod aliases;
pub mod auth;
pub 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;
use axum::Router;
@@ -20,6 +28,14 @@ pub fn router() -> Router<AppState> {
.merge(aliases::routes())
.merge(auth::routes())
.merge(ws::routes())
.merge(calls::routes())
.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)

View File

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

View File

@@ -0,0 +1,136 @@
use axum::{
extract::{Path, State},
routing::get,
Json, Router,
};
use crate::errors::AppResult;
use crate::state::AppState;
/// 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();
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<AppState> {
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<AppState>,
Path(address): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
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,
"numeric_id": fp_to_numeric_id(&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::<serde_json::Value>().await {
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(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,
"numeric_id": fp_to_numeric_id(&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,
"numeric_id": fp_to_numeric_id(&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::<String>();
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,
"numeric_id": fp_to_numeric_id(&fp),
"eth_address": eth,
"type": "fingerprint",
})));
}
Ok(Json(serde_json::json!({ "error": "unrecognized address format" })))
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,9 +21,9 @@ use warzone_protocol::message::WireMessage;
use crate::state::AppState;
/// Try to extract the message ID from raw bincode-serialized WireMessage bytes.
/// Try to extract the message ID from raw WireMessage bytes (envelope or legacy).
fn extract_message_id(data: &[u8]) -> Option<String> {
if let Ok(wire) = bincode::deserialize::<WireMessage>(data) {
if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) {
match wire {
WireMessage::KeyExchange { id, .. } => Some(id),
WireMessage::Message { id, .. } => Some(id),
@@ -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::<serde_json::Value>(&value) {
let wrapper = serde_json::json!({
"type": "missed_call",
"data": missed,
});
if let Ok(json_str) = serde_json::to_string(&wrapper) {
if ws_tx.send(Message::Text(json_str)).await.is_ok() {
missed_keys.push(key);
}
}
}
}
for key in &missed_keys {
let _ = state.db.missed_calls.remove(key);
}
if !missed_keys.is_empty() {
tracing::info!("WS {}: flushed {} missed call notifications", fingerprint, missed_keys.len());
}
// Spawn task to forward push messages to WS
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;
}
}
@@ -108,7 +135,14 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
// For simplicity: first 32 hex chars = recipient fp, rest = message
if data.len() > 64 {
let header = String::from_utf8_lossy(&data[..64]).to_string();
let to_fp = normalize_fp(&header);
let raw_fp = normalize_fp(&header);
// The WS header is 64 hex chars (32 bytes padded with '0').
// Fingerprints are 32 hex chars. Truncate to 32 if zero-padded.
let to_fp = if raw_fp.len() > 32 && raw_fp[32..].chars().all(|c| c == '0') {
raw_fp[..32].to_string()
} else {
raw_fp
};
let message = &data[64..];
// Dedup: skip if we already processed this message ID
@@ -119,13 +153,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, .. }) = warzone_protocol::message::deserialize_envelope(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 +245,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 +277,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);
}
}

View File

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

View File

@@ -4,14 +4,26 @@ use tokio::sync::{Mutex, mpsc};
use crate::db::Database;
/// 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<Vec<u8>>;
/// Connected clients: fingerprint → list of WS senders (multiple devices).
pub type Connections = Arc<Mutex<HashMap<String, Vec<WsSender>>>>;
/// 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<String>,
}
/// Connected clients: fingerprint → list of device connections (multiple devices).
pub type Connections = Arc<Mutex<HashMap<String, Vec<DeviceConnection>>>>;
/// Bounded dedup tracker: FIFO eviction when capacity is exceeded.
#[derive(Clone)]
@@ -47,11 +59,36 @@ impl DedupTracker {
}
}
/// Call lifecycle status.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum CallStatus {
Ringing,
Active,
Ended,
}
/// Server-side state for an active or recently ended call.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CallState {
pub call_id: String,
pub caller_fp: String,
pub callee_fp: String,
pub group_name: Option<String>,
pub room_id: Option<String>,
pub status: CallStatus,
pub created_at: i64,
pub answered_at: Option<i64>,
pub ended_at: Option<i64>,
}
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Database>,
pub connections: Connections,
pub dedup: DedupTracker,
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
pub federation: Option<crate::federation::FederationHandle>,
pub bots_enabled: bool,
}
impl AppState {
@@ -61,16 +98,19 @@ 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,
bots_enabled: false,
})
}
/// 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 +121,294 @@ impl AppState {
}
/// Register a WS connection for a fingerprint.
pub async fn register_ws(&self, fingerprint: &str) -> mpsc::UnboundedReceiver<Vec<u8>> {
///
/// Returns `None` if the per-fingerprint connection cap has been reached.
/// On success, returns the assigned device ID and a receiver for push messages.
pub async fn register_ws(&self, fingerprint: &str, token: Option<String>) -> Option<(String, mpsc::UnboundedReceiver<Vec<u8>>)> {
let (tx, rx) = mpsc::unbounded_channel();
let 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::<usize>());
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::<usize>()
);
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 {
// BotFather: intercept messages to @botfather
if self.bots_enabled && to_fp == "00000000000000000b0ffa00e000000f" {
// Extract sender from message
if let Ok(msg) = serde_json::from_slice::<serde_json::Value>(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;
}
// 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);
// 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
}
/// 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
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_state() -> AppState {
let dir = tempfile::tempdir().unwrap();
AppState::new(dir.path().to_str().unwrap()).unwrap()
}
#[tokio::test]
async fn push_to_client_returns_false_when_offline() {
let state = test_state();
assert!(!state.push_to_client("abc123", b"hello").await);
}
#[tokio::test]
async fn register_ws_and_push() {
let state = test_state();
let (_, mut rx) = state.register_ws("test_fp", None).await.unwrap();
assert!(state.push_to_client("test_fp", b"hello").await);
let msg = rx.recv().await.unwrap();
assert_eq!(msg, b"hello");
}
#[tokio::test]
async fn ws_connection_cap() {
let state = test_state();
// Hold receivers so senders stay open (register_ws prunes closed senders).
let mut _holders = Vec::new();
for i in 0..5 {
let res = state.register_ws("same_fp", None).await;
assert!(res.is_some(), "connection {} should succeed", i);
_holders.push(res.unwrap());
}
// 6th should fail
assert!(state.register_ws("same_fp", None).await.is_none());
}
#[tokio::test]
async fn is_online_and_device_count() {
let state = test_state();
assert!(!state.is_online("fp1").await);
assert_eq!(state.device_count("fp1").await, 0);
// Must hold receivers so the senders are not marked as closed.
let _r1 = state.register_ws("fp1", None).await;
assert!(state.is_online("fp1").await);
assert_eq!(state.device_count("fp1").await, 1);
let _r2 = state.register_ws("fp1", None).await;
assert_eq!(state.device_count("fp1").await, 2);
}
#[tokio::test]
async fn kick_device() {
let state = test_state();
let (device_id, _) = state.register_ws("fp1", None).await.unwrap();
assert!(state.kick_device("fp1", &device_id).await);
assert!(!state.is_online("fp1").await);
}
#[tokio::test]
async fn revoke_all_except() {
let state = test_state();
let (id1, _rx1) = state.register_ws("fp1", None).await.unwrap();
let (_id2, _rx2) = state.register_ws("fp1", None).await.unwrap();
let (_id3, _rx3) = state.register_ws("fp1", None).await.unwrap();
let removed = state.revoke_all_except("fp1", &id1).await;
assert_eq!(removed, 2);
assert_eq!(state.device_count("fp1").await, 1);
}
#[tokio::test]
async fn deliver_or_queue_offline() {
let state = test_state();
// No WS connected -- should queue
let delivered = state.deliver_or_queue("offline_fp", b"test message").await;
assert!(!delivered);
// Check message was queued in DB
let prefix = "queue:offline_fp";
let count = state.db.messages.scan_prefix(prefix.as_bytes()).count();
assert_eq!(count, 1);
}
#[tokio::test]
async fn deliver_or_queue_online() {
let state = test_state();
let (_, mut rx) = state.register_ws("online_fp", None).await.unwrap();
let delivered = state.deliver_or_queue("online_fp", b"instant").await;
assert!(delivered);
let msg = rx.recv().await.unwrap();
assert_eq!(msg, b"instant");
}
#[tokio::test]
async fn call_state_lifecycle() {
let state = test_state();
let call = CallState {
call_id: "call-001".into(),
caller_fp: "alice".into(),
callee_fp: "bob".into(),
group_name: None,
room_id: None,
status: CallStatus::Ringing,
created_at: chrono::Utc::now().timestamp(),
answered_at: None,
ended_at: None,
};
state.active_calls.lock().await.insert("call-001".into(), call);
assert_eq!(state.active_calls.lock().await.len(), 1);
// End the call
if let Some(mut c) = state.active_calls.lock().await.remove("call-001") {
c.status = CallStatus::Ended;
c.ended_at = Some(chrono::Utc::now().timestamp());
let _ = state.db.calls.insert(b"call-001", serde_json::to_vec(&c).unwrap());
}
assert_eq!(state.active_calls.lock().await.len(), 0);
}
#[tokio::test]
async fn list_devices() {
let state = test_state();
let _r1 = state.register_ws("fp1", None).await;
let _r2 = state.register_ws("fp1", None).await;
let devices = state.list_devices("fp1").await;
assert_eq!(devices.len(), 2);
}
}

View File

@@ -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"]

View File

@@ -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<Vec<u8>, JsValue> {
@@ -132,6 +138,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<u32>,
}
#[wasm_bindgen]
@@ -147,6 +156,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 +173,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<Vec<u8>, 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 +189,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()))
@@ -201,16 +212,17 @@ impl WasmSession {
}
pub fn save(&self) -> Result<String, JsValue> {
let bytes = bincode::serialize(&self.ratchet).map_err(|e| JsValue::from_str(&e.to_string()))?;
let bytes = self.ratchet.serialize_versioned()
.map_err(|e| JsValue::from_str(&e))?;
Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes))
}
pub fn restore(data: &str) -> Result<WasmSession, JsValue> {
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data)
.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 })
let ratchet = RatchetState::deserialize_versioned(&bytes)
.map_err(|e| JsValue::from_str(&e))?;
Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None })
}
}
@@ -361,7 +373,7 @@ pub fn decrypt_wire_message(
let seed = Seed::from_bytes(sb);
let id = seed.derive_identity();
let wire: WireMessage = bincode::deserialize(message_bytes)
let wire: WireMessage = warzone_protocol::message::deserialize_envelope(message_bytes)
.map_err(|e| JsValue::from_str(&format!("deserialize wire: {}", e)))?;
match wire {
@@ -392,7 +404,7 @@ pub fn decrypt_wire_message(
let session_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bincode::serialize(&ratchet).unwrap_or_default(),
&ratchet.serialize_versioned().unwrap_or_default(),
);
Ok(serde_json::json!({
@@ -413,15 +425,15 @@ pub fn decrypt_wire_message(
let session_bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD, &session_data,
).map_err(|e| JsValue::from_str(&e.to_string()))?;
let mut ratchet: RatchetState = bincode::deserialize(&session_bytes)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let mut ratchet = RatchetState::deserialize_versioned(&session_bytes)
.map_err(|e| JsValue::from_str(&e))?;
let plain = ratchet.decrypt(&ratchet_message)
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
let session_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bincode::serialize(&ratchet).unwrap_or_default(),
&ratchet.serialize_versioned().unwrap_or_default(),
);
Ok(serde_json::json!({
@@ -474,10 +486,307 @@ 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<String, JsValue> {
use warzone_protocol::sender_keys::{SenderKey, SenderKeyMessage};
let key_bytes = hex::decode(sender_key_hex)
.map_err(|e| JsValue::from_str(&format!("invalid sender key hex: {}", e)))?;
let mut sender_key: SenderKey = bincode::deserialize(&key_bytes)
.map_err(|e| JsValue::from_str(&format!("deserialize sender key: {}", e)))?;
let ciphertext = hex::decode(ciphertext_hex)
.map_err(|e| JsValue::from_str(&format!("invalid ciphertext hex: {}", e)))?;
let msg = SenderKeyMessage {
sender_fingerprint: sender_fingerprint.to_string(),
group_name: group_name.to_string(),
generation,
counter,
ciphertext,
};
let plaintext = sender_key.decrypt(&msg)
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
// Return updated sender key (counter advanced) so JS can persist it
let updated_key = bincode::serialize(&sender_key).unwrap_or_default();
Ok(serde_json::json!({
"text": String::from_utf8_lossy(&plaintext),
"sender_key": hex::encode(updated_key),
}).to_string())
}
/// Create a sender key from a distribution message.
///
/// Takes the fields from a sender_key_distribution JSON and returns
/// a hex-encoded bincode SenderKey that JS should store.
#[wasm_bindgen]
pub fn create_sender_key_from_distribution(
sender_fingerprint: &str,
group_name: &str,
chain_key_hex: &str,
generation: u32,
) -> Result<String, JsValue> {
use warzone_protocol::sender_keys::SenderKeyDistribution;
let chain_key_bytes = hex::decode(chain_key_hex)
.map_err(|e| JsValue::from_str(&format!("invalid chain key hex: {}", e)))?;
let mut chain_key = [0u8; 32];
if chain_key_bytes.len() != 32 {
return Err(JsValue::from_str("chain key must be 32 bytes"));
}
chain_key.copy_from_slice(&chain_key_bytes);
let dist = SenderKeyDistribution {
sender_fingerprint: sender_fingerprint.to_string(),
group_name: group_name.to_string(),
chain_key,
generation,
};
let sender_key = dist.into_sender_key();
let encoded = bincode::serialize(&sender_key).unwrap_or_default();
Ok(hex::encode(encoded))
}
/// 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<Vec<u8>, 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.
#[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");
}
}

View File

@@ -0,0 +1,8 @@
{
"server_id": "kh3rad3ree",
"shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4",
"peer": {
"id": "mequ",
"url": "http://10.66.66.129:7700"
}
}

View File

@@ -0,0 +1,8 @@
{
"server_id": "mequ",
"shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4",
"peer": {
"id": "kh3rad3ree",
"url": "http://10.66.66.253:7700"
}
}

View File

@@ -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

60
warzone/deploy/setup.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
# Setup script — run as root on each server.
# Usage: ./setup.sh <mequ|kh3rad3ree>
HOSTNAME="${1:-}"
if [ -z "$HOSTNAME" ] || { [ "$HOSTNAME" != "mequ" ] && [ "$HOSTNAME" != "kh3rad3ree" ]; }; then
echo "Usage: $0 <mequ|kh3rad3ree>"
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
# 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/
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"

View File

@@ -0,0 +1,2 @@
# kh3rad3ree: federation + bots enabled
EXTRA_ARGS=--enable-bots

View File

@@ -0,0 +1,2 @@
# mequ: federation only, no bots
EXTRA_ARGS=

View File

@@ -0,0 +1,28 @@
[Unit]
Description=Warzone Messenger Server (featherChat)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=warzone
Group=warzone
WorkingDirectory=/home/warzone
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
# 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

File diff suppressed because it is too large Load Diff

440
warzone/docs/BOT_API.md Normal file
View File

@@ -0,0 +1,440 @@
# 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 are created exclusively through **@botfather**, receive a token,
and communicate via long-polling or webhooks.
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) 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. 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...", "botfather_token": "<bf_token>"}
2. Extract the token from the response.
3. Poll for updates:
POST /v1/bot/<token>/getUpdates
{"timeout": 50}
4. Send a reply:
POST /v1/bot/<token>/sendMessage
{"chat_id": "<sender_fingerprint_or_numeric_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. **Only @botfather can call this endpoint** -- a valid `botfather_token`
is required.
**Request:**
```json
{
"name": "MyBot",
"fingerprint": "aabbccdd1122334455667788aabbccdd",
"botfather_token": "<botfather_token>",
"owner": "<creator_fingerprint>"
}
```
| 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:**
```json
{
"ok": true,
"result": {
"token": "aabbccdd11223344:9f8e7d6c5b4a39281706abcdef012345",
"name": "MyBot",
"fingerprint": "aabbccdd1122334455667788aabbccdd",
"alias": "@mybot_bot",
"owner": "<creator_fingerprint>"
}
}
```
**Token format:** `<first-16-chars-of-fingerprint>:<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 50.** |
If the queue is empty and `timeout > 0`, the server waits up to `timeout`
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
{
"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/int | Recipient fingerprint (hex), Ethereum address, or numeric ID. |
| `text` | string | Message body. |
| `parse_mode` | string | Optional. `"HTML"` renders basic tags (<b>, <i>, <code>, <a>). |
`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:**
```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 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 (<b>, <i>, <code>, <a>) |
| 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) |
| 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": 50}).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:<token>`.
- A reverse lookup `bot_fp:<fingerprint>` -> `<token>` is also maintained.
- Aliases are stored in the `aliases` sled tree (`a:<alias>` -> fingerprint,
`fp:<fingerprint>` -> alias).
- Queued messages live in the `messages` sled tree under prefix
`queue:<bot_fingerprint>:*` and are deleted after `getUpdates` consumes them.
- Messages are delivered via `deliver_or_queue` -- live WebSocket if online,
otherwise queued.
---
## 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
- **File send/receive APIs** -- `sendDocument`, `getFile`.
- **Group bot support** -- bots in group chats with sender-key encryption.

View File

@@ -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<Mutex<Vec<ChatLine>>>`.
---
## 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 <recipient-fingerprint> <message> -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 \<words...\>
### `warzone recover <words...>`
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 <recipient> <message>`
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": { "<fp>": "base64_bincode", ... },
"pre_keys": { "spk:1": "base64_bytes", "otpk:1": "base64_bytes", ... }
}
```
---
## 4. Identity Management
### `warzone restore <input>`
### 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 <fp>. 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 <fp_or_alias>` | `/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 <name>` | 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 <name>` | Switch to group (auto-join if needed) |
| `/gcreate <name>` | Create a new group (you become creator) |
| `/gjoin <name>` | Join an existing group |
| `/gleave` | Leave the current group |
| `/gkick <fp_or_alias>` | 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 <path>` | 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 <device_id>` | 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 <address>` | Add a friend (fingerprint or ETH address) |
| `/unfriend <address>` | 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 <address>`: the client fetches the current encrypted blob from
the server, decrypts it, adds the entry, re-encrypts, and uploads.
2. On `/unfriend <address>`: same fetch-decrypt-modify-encrypt-upload cycle.
3. On `/friend` (no argument): fetches and decrypts the blob, then checks
`/v1/presence/<fp>` 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:<fp>` | 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 <fingerprint>` command.
The server stores per-device bundles (`device:<fp>:<device_id>`). 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 <server>`.
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.

268
warzone/docs/LLM_BOT_DEV.md Normal file
View File

@@ -0,0 +1,268 @@
# featherChat Bot Development Reference
## Prerequisites
Server must run with `--enable-bots`:
```bash
warzone-server --bind 0.0.0.0:7700 --enable-bots
```
## Creating a Bot
Message `@botfather` in the chat client (TUI or web):
```
You: /peer @botfather
You: /newbot MyAssistantBot
BotFather: Done! Your new bot @myassistantbot is ready.
Token: a1b2c3d4e5f6a7b8:9876543210abcdef...
Keep this token secret!
```
BotFather commands:
- `/newbot <name>` — create bot (name must end with bot/Bot)
- `/mybots` — list your bots
- `/deletebot <name>` — delete bot you own
- `/token <name>` — 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":123456,"id_str":"aabbccdd...","is_bot":true,"first_name":"MyBot"}}
```
### getUpdates
```
POST /v1/bot/TOKEN/getUpdates
{"offset":LAST_ID+1,"timeout":50,"limit":100}
```
Response:
```json
{"ok":true,"result":[
{"update_id":1,"message":{
"message_id":"uuid",
"from":{"id":123456,"is_bot":false},
"chat":{"id":123456,"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 (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
```
POST /v1/bot/TOKEN/sendMessage
{
"chat_id": "fingerprint_hex_or_numeric_id",
"text": "Hello!",
"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}}
```
`chat_id` accepts: hex fingerprint string, numeric i64, or `0x` ETH address.
`parse_mode` "HTML" renders `<b>`, `<i>`, `<code>`, `<a>` 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}
→ {"ok":true,"result":true}
```
### sendDocument
```
POST /v1/bot/TOKEN/sendDocument
{"chat_id":"..","document":"filename_or_url","caption":"optional"}
```
### Webhooks
```
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
**User message (plaintext — default for bot recipients):**
```json
{"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}}
```
**Bot-to-bot message:**
```json
{"update_id":2,"message":{"message_id":"id","from":{"id":456,"is_bot":true},"chat":{"id":456,"type":"private"},"text":"inter-bot msg","date":1234567890}}
```
**E2E encrypted (user sent without bot detection — rare):**
```json
{"update_id":3,"message":{"text":null,"raw_encrypted":"base64..."}}
```
**File:**
```json
{"update_id":4,"message":{"document":{"file_name":"report.pdf","file_size":1234}}}
```
## Python Echo Bot
```python
import requests, time
TOKEN = "YOUR_TOKEN" # from @botfather /newbot
API = f"http://localhost:7700/v1/bot/{TOKEN}"
offset = 0
while True:
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)
```
## Python Menu Bot (Inline Keyboard)
```python
import requests
TOKEN = "YOUR_TOKEN"
API = f"http://localhost:7700/v1/bot/{TOKEN}"
offset = 0
def menu(chat_id):
requests.post(f"{API}/sendMessage", json={
"chat_id": chat_id, "text": "Pick one:",
"reply_markup": {"inline_keyboard": [
[{"text": "A", "callback_data": "a"}, {"text": "B", "callback_data": "b"}]
]}
})
while True:
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
```javascript
const TOKEN = process.env.BOT_TOKEN;
const API = `http://localhost:7700/v1/bot/${TOKEN}`;
let offset = 0;
(async () => {
while (true) {
try {
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: chat.id, text: `Echo: ${text}`})
});
}
} catch(e) { console.error(e); await new Promise(r => setTimeout(r, 3000)); }
}
})();
```
## 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 | 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 |
| 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) |
| parse_mode HTML | rendered | rendered in web client |
| Media groups | yes | not yet |
## Voice Calls
Bots cannot initiate or participate in voice calls. Voice is peer-to-peer only between human clients (web or TUI). Call signaling messages (`CallSignal` type) are delivered to bots via getUpdates as `text="/call_Offer"` etc., but bots should ignore them -- there is no audio path for bots.
## Key Rules
1. **Always use offset** in getUpdates — without it you reprocess messages
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
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)

244
warzone/docs/LLM_HELP.md Normal file
View File

@@ -0,0 +1,244 @@
# 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 <addr>, /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 <name> | register alias for yourself | /alias alice
/aliases | list all registered aliases | /aliases
/unalias | remove your alias | /unalias
/friend | list friends + online status | /friend
/friend <addr> | add friend | /friend @bob
/unfriend <addr> | remove friend | /unfriend @bob
/devices | list active device sessions | /devices
/kick <id> | kick a device session | /kick dev_abc
/g <name> | switch to group (auto-join) | /g ops
/gcreate <name> | create group | /gcreate ops
/gjoin <name> | join group | /gjoin ops
/glist | list all groups | /glist
/gleave | leave current group | /gleave
/gkick <fp> | kick member (creator only) | /gkick abc123
/gmembers | list group members + status | /gmembers
/file <path> | 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.
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.
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 <fingerprint>` -- set recipient
5. Type msg, press Enter -- encrypted + sent
Recovery: `warzone recover` -- enter 24 words to restore identity on new device.
## Groups
- /gcreate <name> -- create, you become creator + first member
- /gjoin <name> -- join existing (or auto-join via /g <name>)
- 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 <fp> -- creator only, removes member
Groups auto-create on join if they don't exist. Server fans out per-member encrypted msgs.
## Files
/file <path> -- 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 <addr> -- add (fp, ETH, or @alias)
- /unfriend <addr> -- 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 <device_id> -- 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 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
- 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 <name> 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)
### Creating a Bot
Server must run with `--enable-bots`. Then in chat:
```
/peer @botfather
/newbot MyWeatherBot
→ BotFather replies with token
```
BotFather commands: /newbot, /mybots, /deletebot <name>, /token <name>, /help
Bot names must end with bot/Bot/_bot. Only @botfather can create bots.
### 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":50}|
|/bot/:token/sendMessage|POST|{"chat_id":"<fp_or_numeric>","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 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 (<b>, <i>, <code>, <a>) in clients
- 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
- 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}
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":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)
```
## Voice Calls
### Architecture
Call signaling flows through the featherChat WebSocket (offer/answer/hangup/reject/ringing/busy).
Audio flows through a separate WZP relay infrastructure:
```
Browser A <--WS--> wzp-web <--QUIC--> wzp-relay <--QUIC--> wzp-web <--WS--> Browser B
| |
featherChat server (/v1/auth/validate)
```
### Key files
- Call signaling: `warzone-server/src/routes/ws.rs` (WireMessage::CallSignal handling)
- Call state: `warzone-server/src/state.rs` (CallState, active_calls)
- Relay config: `warzone-server/src/routes/wzp.rs` (token issuance)
- Web audio: `warzone-server/src/routes/web.rs` (startAudio/stopAudio functions)
- TUI calls: `warzone-client/src/tui/commands.rs` (/call, /accept, /reject, /hangup)
- Protocol: `warzone-protocol/src/message.rs` (CallSignal, CallSignalType)
### Environment
- `WZP_RELAY_ADDR` -- tells featherChat server where wzp-web bridge is (e.g., `127.0.0.1:8080`)
- Without this, `/v1/wzp/relay-config` returns default `127.0.0.1:4433`
### Commands
cmd | action | example
--- | --- | ---
/call | start voice call with current peer | /call
/call <addr> | start voice call with specific peer | /call @alice
/accept | accept incoming call | /accept
/reject | reject incoming call | /reject
/hangup | end current call | /hangup
## 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=<fp>&token=<tok> -- 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/*

View File

@@ -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.

View File

@@ -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:<token>`), 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

View File

@@ -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,440 @@ 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 |
| `--enable-bots` | | *(off)* | Enable Bot API and auto-create BotFather on startup |
### 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 |
### 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`:
```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 <path>` 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
## 4b. Bot System
```
GET /v1/keys/{fingerprint}
### 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
```
**Path parameter:** the fingerprint string, e.g. `a3f8:c912:44be:7d01`.
### BotFather Auto-Creation
**Response (200):**
```json
{
"fingerprint": "a3f8:c912:44be:7d01",
"bundle": "base64-encoded-bincode-bytes..."
}
```
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.
The `bundle` value is standard base64-encoded bincode. The client decodes
base64, then deserializes with bincode to recover the `PreKeyBundle`.
### Per-Instance Bot Toggle
**Response (404):** returned if no bundle is registered for the fingerprint.
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.
---
### Send Message
## 5. API Reference
```
POST /v1/messages/send
Content-Type: application/json
```
All endpoints are prefixed with `/v1`. The web UI is served at `/`.
**Request body:**
```json
{
"to": "b7d1:e845:0022:9f3a",
"message": [/* bincode-serialized WireMessage as byte array */]
}
```
### Notation
**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.
- **Auth** = requires `Authorization: Bearer <token>` header (write routes).
- **Public** = no authentication needed (read routes).
---
### Poll Messages
### Health
```
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 |
|--------|------|------|-------------|
| GET | `/v1/health` | Public | Health check; returns `{"status":"ok"}` |
---
### Acknowledge Message
### Keys
```
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/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 |
---
## 4. Web UI
### Messages
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/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 |
---
## 5. Database
### Groups
The server uses **sled** (embedded key-value store). All data lives under the
directory specified by `--data-dir`.
| 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) |
### 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) |
### Aliases
| 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 |
---
### 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 |
---
## Voice Calls (WZP Integration)
featherChat supports voice calls via the WarzonePhone (WZP) audio relay. Three components work together:
### Components
| Component | Binary | Port | Purpose |
|-----------|--------|------|---------|
| featherChat server | `warzone-server` | 7700 | Signaling (offer/answer/hangup) + auth tokens |
| WZP relay | `wzp-relay` | 4433 | QUIC audio relay (SFU) |
| WZP web bridge | `wzp-web` | 8080 | Browser WebSocket ↔ QUIC bridge |
### Running
```bash
# 1. WZP relay (QUIC audio)
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# 2. WZP web bridge (browser ↔ relay)
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# 3. featherChat server (with relay address)
WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server
```
### TLS Requirements
| Scenario | TLS needed? | Why |
|----------|-------------|-----|
| localhost dev | No | Browser allows mic on localhost without HTTPS |
| LAN/remote | wzp-web needs TLS | Browsers require HTTPS for `getUserMedia()` on non-localhost |
| Production | All three should use TLS | Security best practice |
For production TLS on wzp-web:
```bash
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate --cert /path/to/cert.pem --key /path/to/key.pem
```
### Auth Flow
1. User clicks Call -> signaling via featherChat WebSocket
2. Call accepted -> both clients fetch `GET /v1/wzp/relay-config`
3. Server returns `{ relay_addr, token, expires_in: 300 }`
4. Clients connect WebSocket to `ws://relay_addr/ws/ROOM`
5. First message: `{"type":"auth","token":"<token>"}`
6. wzp-web validates token against featherChat `/v1/auth/validate`
7. Audio flows: mic -> PCM -> WS -> wzp-web -> QUIC -> wzp-relay -> peer
---
## 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 +506,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 <user@host>` | 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 +630,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.

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

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

410
warzone/docs/TESTING_E2E.md Normal file
View File

@@ -0,0 +1,410 @@
# featherChat End-to-End Testing Guide
**Version:** 0.0.43
---
## Prerequisites
### Local Testing
```bash
# Build everything
cargo build --release --bin warzone-server --bin warzone-client
wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg
# Binaries
./target/release/warzone-server
./target/release/warzone-client
```
### Two-Server Testing (Federation)
```bash
# Server Alpha
./warzone-server --bind 0.0.0.0:7700 --federation alpha.json --enable-bots --bots-config bots.json
# Server Bravo
./warzone-server --bind 0.0.0.0:7700 --federation bravo.json --enable-bots --bots-config bots.json
```
### Voice Call Testing (requires WZP relay)
```bash
# Terminal A: WZP relay (QUIC audio SFU)
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal B: WZP web bridge (browser WebSocket <-> QUIC)
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal C: featherChat server with relay address
export WZP_RELAY_ADDR=127.0.0.1:8080
./warzone-server
```
---
## Test 1: Basic Messaging (TUI ↔ TUI)
### Setup
```bash
# Terminal 1: Server
./target/release/warzone-server
# Terminal 2: User A
./target/release/warzone-client init
./target/release/warzone-client register --server http://localhost:7700
./target/release/warzone-client tui --server http://localhost:7700
# Terminal 3: User B
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client init
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client register --server http://localhost:7700
WARZONE_HOME=~/.warzone-b ./target/release/warzone-client tui --server http://localhost:7700
```
### Steps
1. **User A**: Note the ETH address shown at startup (e.g., `0x85e3D8...`)
2. **User B**: `/peer 0x85e3D8e4a6EEfc048fc80497773D440Bf3487D2b`
3. **User B**: Type `Hello!` and press Enter
4. **User A**: Should see the message with ✓ (sent) → ✓✓ (delivered)
5. **User A**: `/r Hi back!` (reply)
6. **User B**: Should see the reply
### Verify
- [x] Messages delivered in real-time (< 1 second)
- [x] ✓ appears on send, ✓✓ on delivery
- [x] Timestamps show [HH:MM]
- [x] ETH address shown in header
- [x] `/info` shows both ETH and fingerprint
---
## Test 2: Basic Messaging (Web ↔ Web)
### Setup
1. Open browser tab 1: `http://localhost:7700`
2. Click "Generate Identity" → note the ETH address
3. Open browser tab 2 (incognito): `http://localhost:7700`
4. Click "Generate Identity"
### Steps
1. **Tab 2**: Paste Tab 1's ETH address in the peer input box
2. **Tab 2**: Type "Hello from web!" → Send
3. **Tab 1**: Should see the message
4. **Tab 1**: `/peer <tab2_eth_address>` → Type "Hi!" → Send
5. **Tab 2**: Should see the reply
### Verify
- [x] Messages show with 🔒 prefix (E2E encrypted)
- [x] ETH address shown in header (click to copy)
- [x] Markdown renders (**bold**, `code`, etc.)
- [x] Scrollbar visible and working
---
## Test 3: TUI ↔ Web Cross-Client
### Steps
1. Start TUI (User A) and Web (User B) as above
2. **Web**: `/peer <TUI_eth_address>` → Send message
3. **TUI**: Should see the message with terminal bell
4. **TUI**: `/r Hello from terminal!`
5. **Web**: Should see the reply
### Verify
- [x] Cross-client encryption works (TUI encrypts, Web decrypts and vice versa)
- [x] Receipts flow correctly between clients
---
## Test 4: Group Messaging
### Steps
1. **User A**: `/gcreate testgroup`
2. **User A**: `/g testgroup`
3. **User B**: `/g testgroup` (auto-joins)
4. **User A**: Type "Hello group!" → Send
5. **User B**: Should see `UserA [#testgroup]: Hello group!`
6. **User B**: Type "Reply!" → Send
7. **User A**: Should see the reply
### Verify
- [x] Group creation works
- [x] Auto-join on `/g`
- [x] Messages fan-out to all members
- [x] `/gmembers` shows online status (● / ○)
---
## Test 5: Federation (Two Servers)
### Setup
```bash
# Server Alpha (Terminal 1)
./warzone-server --bind 0.0.0.0:7700 --federation alpha.json
# Server Bravo (Terminal 2)
./warzone-server --bind 0.0.0.0:7701 --data-dir ./data-bravo --federation bravo.json
```
`alpha.json`:
```json
{"server_id":"alpha","shared_secret":"test123","peer":{"id":"bravo","url":"http://127.0.0.1:7701"}}
```
`bravo.json`:
```json
{"server_id":"bravo","shared_secret":"test123","peer":{"id":"alpha","url":"http://127.0.0.1:7700"}}
```
### Steps
1. **User A** connects to Alpha (port 7700)
2. **User B** connects to Bravo (port 7701)
3. Wait 5 seconds for federation presence sync
4. **User A**: `/peer <UserB_eth_address>` → Send message
5. **User B**: Should receive the message
### Verify
- [x] Server logs show "Federation: connected to peer"
- [x] `GET /v1/federation/status` returns `"peer_connected": true`
- [x] Messages route across servers transparently
- [x] Key bundles proxy via federation (no "Peer not registered")
- [x] Aliases resolve across servers
---
## Test 6: File Transfer
### Steps
1. Set up two peers (TUI or Web)
2. **Sender**: `/file /path/to/small-file.txt` (must be < 10MB)
3. **Receiver**: Should see "Incoming file..." → chunk progress → "File saved: ..."
4. Verify the file at `~/.warzone/downloads/small-file.txt`
### Verify
- [x] SHA-256 integrity check passes
- [x] File appears in downloads directory
- [x] Progress shown per chunk
---
## Test 7: Call Signaling
### Steps (Web ↔ Web)
1. **User A**: Set peer to User B
2. **User A**: Click 📞 Call button (or `/call`)
3. **User B**: Should see "📞 Incoming call" with Accept/Reject buttons
4. **User B**: Click ✓ Accept
5. Both: Should see "Call connected!" / "🔊 In call"
6. **Either**: Click "End Call" (or `/hangup`)
7. Both: Should see "Call ended"
### Steps (TUI ↔ TUI)
1. **User A**: `/call <peer_address>`
2. **User A**: Header shows yellow "📞 Calling..."
3. **User B**: "📞 Incoming call from ... — /accept or /reject"
4. **User B**: `/accept`
5. **User A**: Header shows green "🔊 0:00" timer
6. **User A** or **B**: `/hangup`
### Verify
- [x] Call bar appears in web when peer is set
- [x] Incoming call notification (pulsing animation in web, bell in TUI)
- [x] Call state updates in header (TUI) / call bar (web)
- [x] Hangup/reject cleans up state on both sides
---
## Test 8: Voice Call Audio (requires WZP relay)
### Prerequisites
```bash
# Terminal 1: WZP relay (QUIC audio SFU)
./wzp-relay --listen 0.0.0.0:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal 2: WZP web bridge (browser WebSocket <-> QUIC)
./wzp-web --port 8080 --relay 127.0.0.1:4433 --auth-url http://127.0.0.1:7700/v1/auth/validate
# Terminal 3: featherChat server
WZP_RELAY_ADDR=127.0.0.1:8080 ./warzone-server
```
### Steps
1. Open two browser tabs to `http://localhost:7700`
2. **Tab 1**: Set peer to Tab 2
3. **Tab 1**: Click 📞 Call
4. **Tab 2**: Click ✓ Accept
5. Both: Allow microphone access when prompted
6. **Speak into mic** — other tab should hear audio
7. End call
### Verify
- [x] "Audio: connecting to ..." message appears
- [x] "Audio: connected — mic active" confirms WS to relay
- [x] Audio flows bidirectionally
- [x] Audio stops on hangup
- [x] No audio leak after call ends
---
## Test 9: Bot API
### Setup
```bash
# Server with bots enabled
./warzone-server --enable-bots --bots-config bots.json
```
### Create a bot via BotFather
1. Open web client
2. `/peer @botfather`
3. Type `/newbot TestEchoBot`
4. Note the token from BotFather's reply
### Run echo bot
```python
import requests, time
TOKEN = "YOUR_TOKEN_HERE"
API = f"http://localhost:7700/v1/bot/{TOKEN}"
offset = 0
while True:
r = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 30}).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 and cid:
requests.post(f"{API}/sendMessage", json={"chat_id": cid, "text": f"Echo: {text}"})
time.sleep(0.1)
```
### Test messaging the bot
1. `/peer @testechobot`
2. Type "Hello bot!"
3. Bot should reply "Echo: Hello bot!"
### Verify
- [x] BotFather creates bot and returns token
- [x] Bot receives plaintext messages (not encrypted)
- [x] Bot replies appear in chat
- [x] Markdown in bot replies renders correctly
- [x] Inline keyboards render as clickable buttons (if bot sends reply_markup)
---
## Test 10: System Bots (from config)
### Verify
1. Start server with `--bots-config bots.json`
2. Check `data/bot-tokens.txt` exists with all tokens
3. Open web client — welcome screen shows "Available bots: @helpbot, @codebot, ..."
4. `/peer @helpbot` → Send "hello" → Bot should respond (if bot process is running)
---
## Test 11: Device Management
### Steps
1. Connect with TUI
2. Open web client (same identity or different)
3. **TUI**: `/devices` — should list both sessions
4. **TUI**: `/kick <web_device_id>`
5. **Web**: Connection should drop
### Verify
- [x] `/devices` shows device IDs and connection times
- [x] `/kick` disconnects the target device
- [x] Max 5 devices per identity enforced
---
## Test 12: Friend List
### Steps
1. **User A**: `/friend <UserB_address>`
2. **User A**: `/friend` (no args) — should list User B with online/offline status
3. **User A**: `/unfriend <UserB_address>`
4. **User A**: `/friend` — should show empty
### Verify
- [x] Friend list persists across restarts (encrypted on server)
- [x] Online/offline status shown
- [x] Add/remove works
---
## Test 13: Session Recovery
### Steps
1. Establish a session between two peers (exchange messages)
2. Delete one peer's session DB: `rm -rf ~/.warzone/db/`
3. Restart that peer's TUI
4. Other peer sends a message
5. Should see "[session reset]" and then re-establish
### Verify
- [x] "[session reset]" message appears
- [x] Subsequent messages work after re-X3DH
---
## Test 14: Auto-Backup
### Steps
1. Start TUI client
2. Wait 5 minutes (or use `/backup` for immediate)
3. Check `~/.warzone/backups/` for `.wzbk` files
4. Only 3 most recent should exist
### Verify
- [x] `/backup` creates file immediately
- [x] Auto-backup runs every 5 minutes
- [x] Old backups rotated (max 3)
---
## Test 15: Protocol Versioning
### Steps
1. Send a message normally — raw bincode (legacy format)
2. Check server logs — should accept it
3. Upgrade client to send envelope format in the future
4. Old server should still accept legacy
5. New server accepts both
### Verify
- [x] Legacy (raw bincode) still works
- [x] Envelope `[WZ][v1][len][payload]` accepted
- [x] Future version envelope rejected with clear error
---
## Quick Smoke Test (5 minutes)
If you only have 5 minutes, test these:
1. `./warzone-server --enable-bots --bots-config bots.json`
2. Open `http://localhost:7700` in two browser tabs
3. Tab 1: Generate identity
4. Tab 2: Generate identity, `/peer <tab1_eth_address>`
5. Tab 2: Send "**Hello!**" → Tab 1 should see bold text
6. Tab 1: `/peer @botfather``/newbot QuickBot` → Note token
7. Start echo bot with the token (Python script above)
8. Tab 1: `/peer @quickbot` → "test" → Should get "Echo: test"
9. Tab 1: `/peer <tab2_address>` → Click 📞 Call → Tab 2: Accept
10. Both: Should see "Call connected!" (audio needs WZP relay running)
---
## Troubleshooting
| Issue | Cause | Fix |
|-------|-------|-----|
| "Peer not registered" | Peer hasn't registered keys | Peer needs to open client first |
| "[message could not be decrypted]" | Stale session or cached bundle | Clear localStorage (web) or delete session DB |
| "alias not found" | Bot/alias doesn't exist on this server | Check `--enable-bots`, wipe data + restart |
| No audio | WZP relay not running | Start `wzp-relay` + `wzp-web` + set `WZP_RELAY_ADDR` |
| Federation not working | Peer server down or wrong config | Check `GET /v1/federation/status` on both |
| "connection limit reached" | 5 devices max | `/devices``/kick` old ones |
| Version mismatch (web) | Old service worker cached | Hard refresh (Cmd+Shift+R) |
| Bot not responding | Bot process not running | Check bot process is polling getUpdates |

View File

@@ -1,6 +1,6 @@
# Warzone Messenger (featherChat) — Usage Guide
# featherChat Usage Guide
**Version:** 0.0.20
**Version:** 0.0.21
---
@@ -11,540 +11,446 @@
Requirements: Rust 1.75+, cargo
```bash
# Clone the repository
git clone <repo-url>
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 |
| `--enable-bots` | *(off)* | Enable Bot API and BotFather |
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 <fingerprint>` 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 <words...>`
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 <recipient> <message>`
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 <input>`
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 <fp_or_alias>` | `/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 <fingerprint>` | 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 <name>` | 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 <name>` | Switch to group (auto-join) |
| `/gcreate <name>` | Create a new group |
| `/gjoin <name>` | Join an existing group |
| `/gleave` | Leave the current group |
| `/gkick <fp_or_alias>` | Kick a member (creator only) |
| `/gmembers` | List members of the current group |
| `/glist` | List all groups on the server |
| Command | Description |
|---------|-------------|
| `/g <name>` | Switch to group (auto-joins if not a member) |
| `/gcreate <name>` | Create a new group (you become creator) |
| `/gjoin <name>` | Join an existing group |
| `/gleave` | Leave the current group |
| `/gkick <fp_or_alias>` | 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 <path>` | Send a file to the current peer or group |
| Command | Description |
|---------|-------------|
| `/alias <name>` | 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 <path>` | 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 <fp>` | 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 <device_id>` | Revoke a specific device session |
#### Friend List
| Command | Description |
|---------|-------------|
| `/friend` | List friends with online/offline status |
| `/friend <address>` | Add a friend by fingerprint or alias |
| `/unfriend <address>` | 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:<fp>` | 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
## Voice Calls
### Seed
### Web Client
1. Set a peer (paste ETH address or use `/peer @alias`)
2. Click the Call button or type `/call`
3. Peer sees "Incoming call" and clicks Accept
4. Both allow microphone access
5. Audio flows -- speak normally
6. Click "End Call" or type `/hangup` to end
Your identity is a 32-byte seed. All keys are deterministically derived from it. **Lose the seed = lose the identity forever.**
### TUI Client
1. `/call <peer_address>` -- initiate call
2. Peer sees notification and can use `/accept` or `/reject`
3. Audio currently requires web client (TUI shows hint)
4. `/hangup` -- end call
### 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:
```
File format: WZS1(4 bytes) + salt(16) + nonce(12) + ciphertext(48)
Encryption: Argon2id(passphrase, salt) → 32-byte key
ChaCha20-Poly1305(key, nonce, seed) → ciphertext
```
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
```
### Commands
| Command | Description |
|---------|-------------|
| `/call` | Start voice call with current peer |
| `/accept` | Accept incoming call |
| `/reject` | Reject incoming call |
| `/hangup` | End current call |
---
## Alias System
## Groups
Aliases provide human-readable names for fingerprints.
### Registration
### Creating and Using Groups
```
/alias alice
/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
```
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 <fingerprint>`
- 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 <fingerprint_or_alias>`.
- 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:<fp>:<device_id>`). 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.
- `/friend <address>` adds a friend (by fingerprint or alias).
- `/friend` lists all friends with their current online/offline status.
- `/unfriend <address>` removes a friend.
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.
---
## 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
warzone backup my-backup.wzb
python tools/bot-bridge.py --token YOUR_BOT_TOKEN --server http://localhost:7700
```
This exports:
- All ratchet sessions (Double Ratchet state)
- All pre-key secrets (signed + one-time)
- Encrypted with HKDF(seed, info="warzone-history") + ChaCha20-Poly1305
See `docs/BOT_API.md` and `docs/LLM_BOT_DEV.md` for full Bot API documentation.
### 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": { "<fp>": "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 <device_id>` 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.

View File

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

283
warzone/scripts/build-bleeding.sh Executable file
View File

@@ -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-43"
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 <command>"
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

608
warzone/scripts/build-linux.sh Executable file
View File

@@ -0,0 +1,608 @@
#!/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 <user@host> [--federation <config.json>]"
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 <host>: Stop service, upload binaries, restart
# ---------------------------------------------------------------------------
do_update() {
local host="${1:-}"
if [ -z "$host" ]; then
echo "Usage: $0 --update <user@host>"
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 <host>: Tail logs
# ---------------------------------------------------------------------------
do_logs() {
local host="${1:-${PROD_SERVERS[0]}}"
echo "=== Logs from $host ==="
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 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
;;
*)
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() {
# 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
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)
# ---------------------------------------------------------------------------
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
;;
--local)
do_local_build
;;
--local-ship)
do_local_ship
;;
--local-clean)
CLEAN_CACHE=1 do_local_build
;;
--upload)
do_upload
;;
*)
echo "Usage: $0 <command> [args]"
echo ""
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 "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 on VM"
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 <user@host> Stop service, upload binaries, restart"
echo " --update-all Update mequ + kh3rad3ree"
echo " --deploy <user@host> 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

38
warzone/tools/README.md Normal file
View File

@@ -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

175
warzone/tools/bot-bridge.py Executable file
View File

@@ -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<token>/methodName
path = self.path
# Strip /bot<token>/ 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()

195
warzone/tools/botfather.py Executable file
View File

@@ -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 <name> - Create a new bot (name must end with bot/Bot)
/mybots - List your bots
/deletebot <n> - Delete a bot you own
/token <name> - 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 <name> - Create a bot (name must end with bot/Bot)\n"
"/mybots - List your bots\n"
"/deletebot <name> - Delete your bot\n"
"/token <name> - 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 <botname>\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 <botname>")
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 <botname>")
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()