Compare commits
122 Commits
e364f437a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
983afc5916 | ||
|
|
81954b1b0c | ||
|
|
7c4e6a1c1e | ||
|
|
db88282bf6 | ||
|
|
5bbc197369 | ||
|
|
87d7ab16c2 | ||
|
|
6f1dbde7cc | ||
|
|
5bc59376f5 | ||
|
|
1295f1c937 | ||
|
|
c37bd7934c | ||
|
|
5764719375 | ||
|
|
a368ab24d2 | ||
|
|
3429f518b1 | ||
|
|
e9182fdb41 | ||
|
|
0b58ddcee5 | ||
|
|
0e7277fb20 | ||
|
|
7628ff7a75 | ||
|
|
3489a7cf74 | ||
|
|
1e47b888c8 | ||
|
|
5415d1f5c8 | ||
|
|
13f2227bf0 | ||
|
|
f04c24187d | ||
|
|
3e583bb04b | ||
|
|
6fee73fc4d | ||
|
|
8b37bd4323 | ||
|
|
b0fa9f92bd | ||
|
|
4118be7ef3 | ||
|
|
76fd8dd81a | ||
|
|
e0e747e005 | ||
|
|
76ee2ab585 | ||
|
|
878847ce89 | ||
|
|
362e7a765b | ||
|
|
9dd7341809 | ||
|
|
6196057f3e | ||
|
|
76cac77259 | ||
|
|
8603087afb | ||
|
|
067f1ea20b | ||
|
|
b9e7b3e05c | ||
|
|
deb220ff2c | ||
|
|
0697c988fa | ||
|
|
1851728a09 | ||
|
|
ea04405199 | ||
|
|
2aa58a4319 | ||
|
|
3efce2ddf4 | ||
|
|
fcbf2d5859 | ||
|
|
953b3bd13a | ||
|
|
210fbbb35b | ||
|
|
7b72f7cba5 | ||
|
|
dbf5d136cf | ||
|
|
f8eaf30bb4 | ||
|
|
3e0889e5dc | ||
|
|
4a4fa9fab4 | ||
|
|
064a730b42 | ||
|
|
65f639052e | ||
|
|
619af027dc | ||
|
|
007ca7521d | ||
|
|
de1ce77fea | ||
|
|
1c7b39c395 | ||
|
|
95e7e0b1a9 | ||
|
|
f7a517d8ea | ||
|
|
2dbbc61dfe | ||
|
|
fb987da8ac | ||
|
|
1601decf33 | ||
|
|
741e6fbcfd | ||
|
|
a4405b4976 | ||
|
|
f4eac7b2aa | ||
|
|
ebaf5df671 | ||
|
|
c9f3e338a7 | ||
|
|
9c70e02eba | ||
|
|
608a160614 | ||
|
|
661de47552 | ||
|
|
86da52acc4 | ||
|
|
653c6c050b | ||
|
|
fff443bb6d | ||
|
|
9811248b7c | ||
|
|
4fb3973403 | ||
|
|
2599ce956a | ||
|
|
708080f7be | ||
|
|
b168ecc609 | ||
|
|
104ba78b85 | ||
|
|
8fad8d8374 | ||
|
|
5b21a0e58b | ||
|
|
fe2b7d8e8a | ||
|
|
c8a95e27e4 | ||
|
|
2ca25fd2bf | ||
|
|
6cf2a1814c | ||
|
|
4fc1cc2ab1 | ||
|
|
1aba435af3 | ||
|
|
de3b74bb9d | ||
|
|
54a66fa0ee | ||
|
|
99783c1fa4 | ||
|
|
9814b0d39e | ||
|
|
c966f3bd64 | ||
|
|
19f316c32b | ||
|
|
99da095a0f | ||
|
|
ab296df825 | ||
|
|
c7a31c674e | ||
|
|
40ea631283 | ||
|
|
d7b71efdbc | ||
|
|
c8b51fa96b | ||
|
|
cfb227a93d | ||
|
|
3ffac0c751 | ||
|
|
37a4c3c54f | ||
|
|
7fe6de0ba1 | ||
|
|
bf67566b0c | ||
|
|
29c059cebf | ||
|
|
b90155c3b7 | ||
|
|
5cf7e8a02f | ||
|
|
f3e78c6cff | ||
|
|
7b1e0bd162 | ||
|
|
a298c9430c | ||
|
|
6d4a09a0c6 | ||
|
|
8a6eebabfd | ||
|
|
bc64afcb05 | ||
|
|
8dd45b1bfe | ||
|
|
de118371de | ||
|
|
cf7e935250 | ||
|
|
2efd355983 | ||
|
|
722441c391 | ||
|
|
94b845eb5b | ||
|
|
60a7006ed9 | ||
|
|
82f5061aa1 |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "warzone-phone"]
|
||||
path = warzone-phone
|
||||
url = ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git
|
||||
82
DESIGN.md
82
DESIGN.md
@@ -42,6 +42,18 @@ seed (32 bytes) → Ed25519 signing keypair + X25519 encryption keypair
|
||||
| CLI | `~/.warzone/identity.seed` (encrypted with passphrase via Argon2 + ChaCha20) |
|
||||
| Browser | IndexedDB (non-extractable CryptoKey) + seed backup prompt on first run |
|
||||
| Mobile (PWA) | Same as browser, seed shown as QR code for device transfer |
|
||||
| Hardware wallet | Seed never leaves device. Ledger/Trezor sign via USB/BT HID. (Phase 2) |
|
||||
|
||||
### Hardware Wallet Support (Phase 2)
|
||||
|
||||
Ledger and Trezor can act as the key storage backend:
|
||||
- Seed lives on the hardware wallet, never exported
|
||||
- Ed25519 signing delegated to device (BIP44 path `m/44'/1234'/0'`)
|
||||
- X25519 encryption key derived from Ed25519 via birkhoff conversion, or separate derivation path
|
||||
- Client sends challenge → wallet displays → user confirms on device → signed response
|
||||
- No passphrase needed (device handles authentication)
|
||||
- Crates: `ledger-transport` (Ledger), `trezor-client` (Trezor)
|
||||
- Protocol is unchanged — only the `KeyStore` backend differs
|
||||
|
||||
### Device Transfer
|
||||
|
||||
@@ -396,27 +408,63 @@ warzone.wasm # browser client (via wasm-pack)
|
||||
- [x] File upload
|
||||
|
||||
### Phase 1 — Identity & Crypto Foundation (Rust)
|
||||
- [ ] Rust project scaffold (cargo workspace: server, client, protocol, mule)
|
||||
- [ ] Seed-based identity (Ed25519 + X25519 from 32-byte seed)
|
||||
- [ ] BIP39 mnemonic generation and recovery
|
||||
- [ ] Seed encryption at rest (Argon2 + ChaCha20-Poly1305)
|
||||
- [ ] Pre-key bundle generation and storage
|
||||
- [ ] X3DH key exchange implementation
|
||||
- [ ] Double Ratchet for 1:1 messaging
|
||||
- [ ] Message signing (Ed25519)
|
||||
- [ ] Basic server: accept connections, store-and-forward
|
||||
- [x] Rust project scaffold (cargo workspace: server, client, protocol, mule, wasm)
|
||||
- [x] Seed-based identity (Ed25519 + X25519 from 32-byte seed)
|
||||
- [x] BIP39 mnemonic generation and recovery
|
||||
- [x] Seed encryption at rest (Argon2 + ChaCha20-Poly1305, unlock once per session)
|
||||
- [x] Pre-key bundle generation and storage
|
||||
- [x] X3DH key exchange implementation
|
||||
- [x] Double Ratchet for 1:1 messaging (forward secrecy, out-of-order)
|
||||
- [x] Basic server: axum, sled DB, store-and-forward
|
||||
- [x] CLI TUI client (ratatui, real-time chat)
|
||||
- [x] Web client with WASM (same crypto as CLI, full interop)
|
||||
- [x] Group chat (server fan-out, per-member encryption)
|
||||
- [x] Aliases with TTL, recovery keys, reclamation
|
||||
- [x] Server auth (challenge-response, bearer tokens)
|
||||
- [x] OTP key replenishment
|
||||
- [x] Fetch-and-delete delivery
|
||||
- [x] 17 protocol tests
|
||||
- [x] WASM bridge for web↔CLI interop (same crypto on both clients)
|
||||
|
||||
### Phase 2 — Core Messaging
|
||||
- [ ] 1:1 E2E encrypted messaging (full Signal protocol)
|
||||
- [ ] Offline message queuing with TTL
|
||||
- [ ] Multi-device support (device list signed by identity key)
|
||||
- [ ] Sender Keys for group encryption
|
||||
- [ ] Group management (create, invite, leave, kick)
|
||||
- [ ] File transfer (chunked, encrypted)
|
||||
- [ ] WebSocket real-time push (replace HTTP polling with instant delivery)
|
||||
- [ ] Delivery receipts (sent, delivered, read)
|
||||
- [ ] File transfer (chunked, encrypted)
|
||||
- [ ] Multi-device support (device list signed by identity key)
|
||||
- [ ] Sender Keys for group encryption (replace per-member fan-out)
|
||||
- [ ] Group management (kick, leave, key rotation)
|
||||
- [ ] Message ordering and deduplication
|
||||
- [ ] TUI client (ratatui)
|
||||
- [ ] Web client (WASM)
|
||||
- [ ] Ethereum-compatible identity (dual-curve: secp256k1 + X25519 from same BIP39 seed)
|
||||
- Fingerprint = Ethereum address (Keccak-256 of secp256k1 pubkey)
|
||||
- BIP44 paths: m/44'/60'/0'/0/0 (Ethereum), m/44'/1234'/0' (Warzone X25519)
|
||||
- MetaMask/Rabby wallet connect (sign challenge → derive session)
|
||||
- Hardware wallet support via existing secp256k1 (Ledger/Trezor)
|
||||
- ENS domain resolution (@vitalik.eth → 0xd8dA... → Warzone identity)
|
||||
- Crates: k256, tiny-keccak, ethers-rs/alloy for ENS resolution
|
||||
- Session key delegation from hardware wallet (sign once per 30 days)
|
||||
- [x] TUI client (ratatui)
|
||||
- [x] Web client (WASM)
|
||||
- [x] WebSocket real-time push
|
||||
- [x] Delivery receipts (sent/delivered/read)
|
||||
- [ ] Progressive Web App (PWA)
|
||||
- Web manifest with standalone display mode
|
||||
- Service worker for offline shell + notification support
|
||||
- Install prompt (Android Chrome "Add to Home Screen")
|
||||
- iOS: apple-mobile-web-app-capable meta tags
|
||||
- Push notifications via service worker (when tab unfocused)
|
||||
- Offline: show cached identity + "reconnecting" state
|
||||
- App icon (SVG, maskable)
|
||||
- [ ] Encrypted local message history & cloud backup
|
||||
- Messages encrypted at rest using key derived from seed (HKDF, info="warzone-history")
|
||||
- No extra password needed — if you have your seed, you can read your history
|
||||
- Optional passphrase for additional protection (double encryption)
|
||||
- Browser: encrypted blob in IndexedDB, export as file
|
||||
- CLI: encrypted sled DB (already has seed-encrypted keystore)
|
||||
- Cloud backup targets: S3-compatible, Google Drive, WebDAV
|
||||
- Backup format: encrypted archive (ChaCha20-Poly1305), versioned, deduplicated
|
||||
- Restore: import backup + provide seed → decrypt and merge history
|
||||
- Sync: periodic incremental backup (new messages since last backup)
|
||||
- Privacy: backup provider sees only encrypted blobs, no metadata
|
||||
|
||||
### Phase 3 — Federation & Key Transparency
|
||||
- [ ] DNS TXT record format specification (server discovery + user key transparency)
|
||||
|
||||
1
warzone-phone
Submodule
1
warzone-phone
Submodule
Submodule warzone-phone added at 6f4e8eb9f6
88
warzone/CLAUDE.md
Normal file
88
warzone/CLAUDE.md
Normal 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` |
|
||||
498
warzone/Cargo.lock
generated
498
warzone/Cargo.lock
generated
@@ -141,6 +141,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -159,8 +160,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -188,6 +191,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -308,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"
|
||||
@@ -507,6 +522,24 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -514,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",
|
||||
]
|
||||
|
||||
@@ -580,6 +613,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -597,6 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
@@ -612,6 +652,21 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"serdect",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
@@ -631,7 +686,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
@@ -644,6 +699,26 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"serdect",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -675,6 +750,16 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
@@ -748,6 +833,17 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
@@ -767,6 +863,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
@@ -789,6 +887,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -798,8 +897,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"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]]
|
||||
@@ -810,11 +925,22 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -972,6 +1098,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1252,6 +1379,21 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "k256"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"once_cell",
|
||||
"serdect",
|
||||
"sha2",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1312,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"
|
||||
@@ -1503,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",
|
||||
]
|
||||
|
||||
@@ -1595,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"
|
||||
@@ -1604,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"
|
||||
@@ -1617,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]]
|
||||
@@ -1628,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]]
|
||||
@@ -1640,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"
|
||||
@@ -1720,6 +1958,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1727,6 +1967,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
"tower-service",
|
||||
@@ -1734,6 +1975,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1750,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"
|
||||
@@ -1792,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",
|
||||
@@ -1804,6 +2063,7 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -1845,6 +2105,21 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"serdect",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -1940,6 +2215,27 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serdect"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -2003,7 +2299,8 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"digest",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2165,13 +2462,33 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2194,6 +2511,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
@@ -2267,6 +2593,34 @@ 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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -2286,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",
|
||||
@@ -2422,6 +2780,45 @@ 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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -2500,6 +2897,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -2553,30 +2956,40 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-client"
|
||||
version = "0.1.0"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"base64",
|
||||
"bincode",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"rand",
|
||||
"libc",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sled",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"warzone-protocol",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "warzone-mule"
|
||||
version = "0.1.0"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -2585,7 +2998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-protocol"
|
||||
version = "0.1.0"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -2596,11 +3009,13 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"rand",
|
||||
"k256",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tiny-keccak",
|
||||
"uuid",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
@@ -2608,19 +3023,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-server"
|
||||
version = "0.1.0"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"bincode",
|
||||
"chrono",
|
||||
"clap",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sled",
|
||||
"thiserror",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.21.0",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
@@ -2629,6 +3052,26 @@ dependencies = [
|
||||
"warzone-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "warzone-wasm"
|
||||
version = "0.0.44"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"js-sys",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
"warzone-protocol",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -2756,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"
|
||||
@@ -3040,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",
|
||||
]
|
||||
|
||||
@@ -5,10 +5,11 @@ members = [
|
||||
"crates/warzone-server",
|
||||
"crates/warzone-client",
|
||||
"crates/warzone-mule",
|
||||
"crates/warzone-wasm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.0.44"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
@@ -24,6 +25,10 @@ sha2 = "0.10"
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
|
||||
# Ethereum compatibility
|
||||
k256 = { version = "0.13", features = ["ecdsa", "serde"] }
|
||||
tiny-keccak = { version = "2", features = ["keccak"] }
|
||||
|
||||
# BIP39
|
||||
bip39 = "2"
|
||||
|
||||
@@ -36,8 +41,8 @@ bincode = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Server
|
||||
axum = "0.7"
|
||||
tower = "0.4"
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
tower = { version = "0.4", features = ["limit"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
|
||||
# Client HTTP
|
||||
@@ -73,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
165
warzone/README.md
Normal 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
|
||||
553
warzone/UAT/PHASE1.md
Normal file
553
warzone/UAT/PHASE1.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# Phase 1 — User Acceptance Testing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
cd warzone
|
||||
cargo build
|
||||
rm -rf warzone-data # clean server DB
|
||||
```
|
||||
|
||||
Open 3 terminals:
|
||||
- **T1**: Server
|
||||
- **T2**: Alice (default `~/.warzone`)
|
||||
- **T3**: Bob (`WARZONE_HOME=/tmp/bob`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Server Startup
|
||||
|
||||
**T1:**
|
||||
```bash
|
||||
cargo run --bin warzone-server
|
||||
```
|
||||
|
||||
- [ ] Server prints "Listening on 0.0.0.0:7700"
|
||||
- [ ] `curl http://localhost:7700/v1/health` returns `{"status":"ok","version":"0.1.0"}`
|
||||
- [ ] `http://localhost:7700/` loads the web UI in a browser
|
||||
|
||||
---
|
||||
|
||||
## 2. Identity Generation
|
||||
|
||||
**T2 (Alice):**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- init
|
||||
```
|
||||
|
||||
- [ ] Prompted "Set passphrase (empty for no encryption):"
|
||||
- [ ] Input is hidden (no echo)
|
||||
- [ ] Prompted "Confirm passphrase:"
|
||||
- [ ] Fingerprint displayed in format `xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx`
|
||||
- [ ] 24-word BIP39 mnemonic displayed
|
||||
- [ ] Seed path shown (e.g. `/Users/you/.warzone/identity.seed`)
|
||||
- [ ] "Generated 1 signed pre-key + 10 one-time pre-keys" shown
|
||||
- [ ] File `~/.warzone/identity.seed` exists
|
||||
- [ ] File `~/.warzone/bundle.bin` exists
|
||||
- [ ] File permissions on identity.seed are 600 (Unix): `ls -la ~/.warzone/identity.seed`
|
||||
|
||||
**T3 (Bob):**
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/bob cargo run --bin warzone-client -- init
|
||||
```
|
||||
|
||||
- [ ] Bob gets a different fingerprint than Alice
|
||||
- [ ] Seed saved to `/tmp/bob/identity.seed`
|
||||
- [ ] Bob's mnemonic is different from Alice's
|
||||
|
||||
---
|
||||
|
||||
## 3. Seed Encryption
|
||||
|
||||
**T2 (Alice):**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- info
|
||||
```
|
||||
|
||||
- [ ] Prompted for passphrase (if one was set during init)
|
||||
- [ ] Fingerprint, signing key, and encryption key displayed
|
||||
- [ ] Same fingerprint as during init
|
||||
- [ ] Wrong passphrase shows "Wrong passphrase" error
|
||||
|
||||
**Test plaintext seed (empty passphrase):**
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/test cargo run --bin warzone-client -- init
|
||||
# press Enter twice for empty passphrase
|
||||
xxd /tmp/test/identity.seed | head -1
|
||||
```
|
||||
|
||||
- [ ] File is exactly 32 bytes (raw seed, no encryption header)
|
||||
|
||||
**Test encrypted seed:**
|
||||
```bash
|
||||
xxd ~/.warzone/identity.seed | head -1
|
||||
```
|
||||
|
||||
- [ ] File starts with `575a 5331` (hex for "WZS1" magic bytes)
|
||||
- [ ] File is larger than 32 bytes (salt + nonce + ciphertext)
|
||||
|
||||
---
|
||||
|
||||
## 4. Mnemonic Recovery
|
||||
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/recovered cargo run --bin warzone-client -- recover <paste 24 words from Alice's init>
|
||||
```
|
||||
|
||||
- [ ] "Identity recovered!" shown
|
||||
- [ ] Fingerprint matches Alice's original fingerprint
|
||||
- [ ] `WARZONE_HOME=/tmp/recovered cargo run --bin warzone-client -- info` shows same keys
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Registration
|
||||
|
||||
**T2 (Alice):**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- register -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] "Bundle registered with http://localhost:7700"
|
||||
|
||||
**T3 (Bob):**
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/bob cargo run --bin warzone-client -- register -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] "Bundle registered with http://localhost:7700"
|
||||
|
||||
**Verify on server:**
|
||||
```bash
|
||||
curl http://localhost:7700/v1/keys/list
|
||||
```
|
||||
|
||||
- [ ] JSON shows 2 keys with Alice's and Bob's fingerprints (hex, no colons)
|
||||
|
||||
**Verify lookup works:**
|
||||
```bash
|
||||
curl http://localhost:7700/v1/keys/<bob-fingerprint-no-colons>
|
||||
```
|
||||
|
||||
- [ ] Returns JSON with `fingerprint` and `bundle` (base64 string)
|
||||
- [ ] Does NOT return 404
|
||||
|
||||
---
|
||||
|
||||
## 6. 1:1 E2E Encrypted Messaging (CLI)
|
||||
|
||||
**T2 (Alice sends to Bob):**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- send "<bob-fingerprint>" "Hello from Alice" -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] "No existing session. Fetching key bundle for ..."
|
||||
- [ ] "Message sent to <bob-fingerprint>"
|
||||
|
||||
**T3 (Bob receives):**
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/bob cargo run --bin warzone-client -- recv -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] "Received 1 message(s):"
|
||||
- [ ] `[new session] <alice-fingerprint>: Hello from Alice`
|
||||
|
||||
**Bob sends reply:**
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/bob cargo run --bin warzone-client -- send "<alice-fingerprint>" "Hi Alice, Bob here" -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] "Message sent to ..." (no "new session" — reuses existing ratchet)
|
||||
|
||||
**Alice receives:**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- recv -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] `[new session] <bob-fingerprint>: Hi Alice, Bob here`
|
||||
|
||||
---
|
||||
|
||||
## 7. Fetch-and-Delete (No Duplicate Delivery)
|
||||
|
||||
**T3 (Bob polls again):**
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/bob cargo run --bin warzone-client -- recv -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] "No new messages." (Alice's message was deleted on first poll)
|
||||
|
||||
---
|
||||
|
||||
## 8. TUI Chat (CLI)
|
||||
|
||||
**T2 (Alice):**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- chat "<bob-fingerprint>" -s http://localhost:7700
|
||||
```
|
||||
|
||||
**T3 (Bob):**
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/bob cargo run --bin warzone-client -- chat "<alice-fingerprint>" -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] Both TUIs launch with header showing fingerprints
|
||||
- [ ] Alice types "hello from TUI" → Enter
|
||||
- [ ] Message appears in green on Alice's screen
|
||||
- [ ] Within 2 seconds, message appears in yellow on Bob's screen
|
||||
- [ ] Bob types "reply from Bob" → Enter
|
||||
- [ ] Message appears on both screens
|
||||
- [ ] `/info` shows fingerprint
|
||||
- [ ] `/quit` exits TUI cleanly (terminal restored)
|
||||
- [ ] Ctrl+C also exits cleanly
|
||||
- [ ] Esc also exits cleanly
|
||||
|
||||
---
|
||||
|
||||
## 9. Groups (CLI TUI)
|
||||
|
||||
**T2 (Alice, in TUI):**
|
||||
```
|
||||
/g ops
|
||||
```
|
||||
|
||||
- [ ] "Joined 'ops'" or "Group 'ops' auto-created"
|
||||
- [ ] "Switched to group #ops"
|
||||
|
||||
**T3 (Bob, in TUI):**
|
||||
```
|
||||
/g ops
|
||||
```
|
||||
|
||||
- [ ] "Joined 'ops'"
|
||||
- [ ] "Switched to group #ops"
|
||||
|
||||
**Alice types a message:**
|
||||
```
|
||||
hello team
|
||||
```
|
||||
|
||||
- [ ] Message appears on Alice's screen with `[#ops]` tag
|
||||
- [ ] Message appears on Bob's screen within 2 seconds
|
||||
|
||||
**Bob replies:**
|
||||
```
|
||||
hey alice!
|
||||
```
|
||||
|
||||
- [ ] Appears on both screens
|
||||
|
||||
**Test group list:**
|
||||
```
|
||||
/glist
|
||||
```
|
||||
|
||||
- [ ] Shows `#ops (2 members)`
|
||||
|
||||
**Switch back to DM:**
|
||||
```
|
||||
/dm
|
||||
```
|
||||
|
||||
- [ ] "Switched to DM mode"
|
||||
|
||||
---
|
||||
|
||||
## 10. Aliases (CLI TUI)
|
||||
|
||||
**T2 (Alice, in TUI):**
|
||||
```
|
||||
/alias alice
|
||||
```
|
||||
|
||||
- [ ] "Alias @alice registered"
|
||||
|
||||
**T3 (Bob, in TUI):**
|
||||
```
|
||||
/alias bob
|
||||
```
|
||||
|
||||
- [ ] "Alias @bob registered"
|
||||
|
||||
**Alice sets peer by alias:**
|
||||
```
|
||||
/peer @bob
|
||||
```
|
||||
|
||||
- [ ] "@bob → <bob-fingerprint>" resolved
|
||||
- [ ] "Peer set to <bob-fingerprint>"
|
||||
|
||||
**List aliases:**
|
||||
```
|
||||
/aliases
|
||||
```
|
||||
|
||||
- [ ] Shows `@alice → <fp>` and `@bob → <fp>`
|
||||
|
||||
---
|
||||
|
||||
## 11. Web UI — Identity
|
||||
|
||||
Open `http://localhost:7700/` in a browser.
|
||||
|
||||
- [ ] "WARZONE" title and "Generate Identity" button shown
|
||||
- [ ] Click "Generate Identity"
|
||||
- [ ] Fingerprint displayed in green
|
||||
- [ ] Hex seed displayed in orange
|
||||
- [ ] "Enter Chat" button shown
|
||||
- [ ] Click "Enter Chat"
|
||||
- [ ] Chat screen loads with header showing fingerprint
|
||||
- [ ] "Key registered with server" message appears
|
||||
- [ ] Refresh page → auto-loads identity (no setup screen)
|
||||
|
||||
---
|
||||
|
||||
## 12. Web UI — DM
|
||||
|
||||
Open TWO browser tabs/windows (or incognito for second identity).
|
||||
|
||||
**Tab 1:** Generate identity → Enter Chat
|
||||
**Tab 2:** Generate identity → Enter Chat
|
||||
|
||||
**Tab 1:** Paste Tab 2's fingerprint in peer input field. Type "hello from tab 1". Enter.
|
||||
|
||||
- [ ] Message appears in green on Tab 1
|
||||
- [ ] Message appears with lock icon on Tab 2 within 2 seconds
|
||||
|
||||
**Tab 2:** Paste Tab 1's fingerprint. Type "hello back". Enter.
|
||||
|
||||
- [ ] Message appears on both tabs
|
||||
|
||||
---
|
||||
|
||||
## 13. Web UI — Groups
|
||||
|
||||
**Tab 1:**
|
||||
```
|
||||
/g webteam
|
||||
```
|
||||
|
||||
- [ ] "Joined group" and "Switched to group" messages
|
||||
|
||||
**Tab 2:**
|
||||
```
|
||||
/g webteam
|
||||
```
|
||||
|
||||
- [ ] Also joined
|
||||
|
||||
**Tab 1:** Type "hello webteam" → Enter
|
||||
|
||||
- [ ] Message appears on Tab 1 with `[webteam]` tag
|
||||
- [ ] Message appears on Tab 2 within 2 seconds
|
||||
|
||||
---
|
||||
|
||||
## 14. Web UI — Aliases
|
||||
|
||||
**Tab 1:**
|
||||
```
|
||||
/alias webuser1
|
||||
```
|
||||
|
||||
- [ ] "Alias @webuser1 registered"
|
||||
|
||||
**Tab 1:**
|
||||
```
|
||||
/info
|
||||
```
|
||||
|
||||
- [ ] Shows fingerprint with `(@webuser1)` suffix
|
||||
|
||||
**Tab 2:** Set peer input to `@webuser1`. Type message. Enter.
|
||||
|
||||
- [ ] Message delivered (alias resolved to fingerprint)
|
||||
|
||||
---
|
||||
|
||||
## 15. Alias TTL & Recovery
|
||||
|
||||
**Register alias via curl:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7700/v1/alias/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"alias":"testuser","fingerprint":"<alice-fp-no-colons>"}'
|
||||
```
|
||||
|
||||
- [ ] Response includes `recovery_key` (32-char hex)
|
||||
- [ ] Response includes `expires_in_days: 365`
|
||||
- [ ] **SAVE THE RECOVERY KEY**
|
||||
|
||||
**Check alias:**
|
||||
```bash
|
||||
curl http://localhost:7700/v1/alias/resolve/testuser
|
||||
```
|
||||
|
||||
- [ ] Returns fingerprint + `expires_in_days`
|
||||
|
||||
**Recover alias to new fingerprint:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7700/v1/alias/recover \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"alias":"testuser","recovery_key":"<saved-key>","new_fingerprint":"<bob-fp-no-colons>"}'
|
||||
```
|
||||
|
||||
- [ ] "ok: true"
|
||||
- [ ] `new_recovery_key` returned (rotated)
|
||||
|
||||
**Verify transfer:**
|
||||
```bash
|
||||
curl http://localhost:7700/v1/alias/resolve/testuser
|
||||
```
|
||||
|
||||
- [ ] Now points to Bob's fingerprint
|
||||
|
||||
**Wrong recovery key:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7700/v1/alias/recover \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"alias":"testuser","recovery_key":"wrong","new_fingerprint":"aaaa"}'
|
||||
```
|
||||
|
||||
- [ ] "error: invalid recovery key"
|
||||
|
||||
---
|
||||
|
||||
## 16. Server Auth (Challenge-Response)
|
||||
|
||||
**Request challenge:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7700/v1/auth/challenge \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"fingerprint":"<alice-fp-no-colons>"}'
|
||||
```
|
||||
|
||||
- [ ] Returns `challenge` (64-char hex) and `expires_at` (unix timestamp)
|
||||
- [ ] Challenge expires in ~60 seconds
|
||||
|
||||
---
|
||||
|
||||
## 17. OTP Key Replenishment
|
||||
|
||||
**Check count:**
|
||||
```bash
|
||||
curl http://localhost:7700/v1/keys/<alice-fp-no-colons>/otpk-count
|
||||
```
|
||||
|
||||
- [ ] Returns `otpk_count` (number, may be 0 if not yet stored separately)
|
||||
|
||||
**Replenish:**
|
||||
```bash
|
||||
curl -X POST http://localhost:7700/v1/keys/replenish \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"fingerprint":"<alice-fp-no-colons>","otpks":[{"id":100,"public_key":"aa"},{"id":101,"public_key":"bb"}]}'
|
||||
```
|
||||
|
||||
- [ ] Returns `stored: 2` and `total` count
|
||||
|
||||
**Verify count increased:**
|
||||
```bash
|
||||
curl http://localhost:7700/v1/keys/<alice-fp-no-colons>/otpk-count
|
||||
```
|
||||
|
||||
- [ ] `otpk_count` increased by 2
|
||||
|
||||
---
|
||||
|
||||
## 18. Protocol Unit Tests
|
||||
|
||||
```bash
|
||||
cargo test -p warzone-protocol
|
||||
```
|
||||
|
||||
- [ ] `identity::tests::deterministic_derivation` — PASS
|
||||
- [ ] `identity::tests::mnemonic_roundtrip` — PASS
|
||||
- [ ] `identity::tests::fingerprint_display` — PASS
|
||||
- [ ] `mnemonic::tests::roundtrip` — PASS
|
||||
- [ ] `crypto::tests::aead_roundtrip` — PASS
|
||||
- [ ] `crypto::tests::aead_wrong_key_fails` — PASS
|
||||
- [ ] `crypto::tests::aead_wrong_aad_fails` — PASS
|
||||
- [ ] `crypto::tests::hkdf_deterministic` — PASS
|
||||
- [ ] `prekey::tests::signed_pre_key_verify` — PASS
|
||||
- [ ] `prekey::tests::signed_pre_key_reject_tampered` — PASS
|
||||
- [ ] `prekey::tests::generate_otpks` — PASS
|
||||
- [ ] `x3dh::tests::x3dh_shared_secret_matches` — PASS
|
||||
- [ ] `ratchet::tests::basic_exchange` — PASS
|
||||
- [ ] `ratchet::tests::bidirectional` — PASS
|
||||
- [ ] `ratchet::tests::multiple_messages_same_direction` — PASS
|
||||
- [ ] `ratchet::tests::out_of_order` — PASS
|
||||
- [ ] `ratchet::tests::many_messages` — PASS
|
||||
|
||||
**Total: 17/17 PASS**
|
||||
|
||||
---
|
||||
|
||||
## 19. Session Persistence
|
||||
|
||||
**T2 (Alice, send then quit):**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- send "<bob-fp>" "message 1" -s http://localhost:7700
|
||||
cargo run --bin warzone-client -- send "<bob-fp>" "message 2" -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] First send says "No existing session" (X3DH)
|
||||
- [ ] Second send does NOT say "No existing session" (reuses saved ratchet)
|
||||
- [ ] `ls ~/.warzone/db/` shows sled database files
|
||||
|
||||
**T3 (Bob receives both):**
|
||||
```bash
|
||||
WARZONE_HOME=/tmp/bob cargo run --bin warzone-client -- recv -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] Both messages decrypted correctly
|
||||
- [ ] Messages in order
|
||||
|
||||
---
|
||||
|
||||
## 20. Cross-Client Compatibility
|
||||
|
||||
**Web → CLI:**
|
||||
|
||||
Web Tab sends message to CLI Alice's fingerprint.
|
||||
|
||||
- [ ] CLI `recv` shows `[encrypted message from CLI client — use CLI to read]` OR fails gracefully
|
||||
- [ ] No crash on either side
|
||||
|
||||
**CLI → Web:**
|
||||
|
||||
CLI Alice sends to Web Tab's fingerprint.
|
||||
|
||||
- [ ] Web shows graceful error (different crypto) or ignores silently
|
||||
- [ ] No crash on either side
|
||||
|
||||
**Note:** Web↔CLI interop requires WASM bridge (Phase 2). Currently incompatible crypto is expected.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Feature | Result |
|
||||
|---|---------|--------|
|
||||
| 1 | Server startup | ☐ |
|
||||
| 2 | Identity generation | ☐ |
|
||||
| 3 | Seed encryption | ☐ |
|
||||
| 4 | Mnemonic recovery | ☐ |
|
||||
| 5 | Key registration | ☐ |
|
||||
| 6 | 1:1 E2E messaging | ☐ |
|
||||
| 7 | Fetch-and-delete | ☐ |
|
||||
| 8 | TUI chat | ☐ |
|
||||
| 9 | Groups (CLI) | ☐ |
|
||||
| 10 | Aliases (CLI) | ☐ |
|
||||
| 11 | Web UI identity | ☐ |
|
||||
| 12 | Web UI DM | ☐ |
|
||||
| 13 | Web UI groups | ☐ |
|
||||
| 14 | Web UI aliases | ☐ |
|
||||
| 15 | Alias TTL & recovery | ☐ |
|
||||
| 16 | Server auth | ☐ |
|
||||
| 17 | OTP replenishment | ☐ |
|
||||
| 18 | Protocol tests (17/17) | ☐ |
|
||||
| 19 | Session persistence | ☐ |
|
||||
| 20 | Cross-client compat | ☐ |
|
||||
|
||||
**Tester:** _______________
|
||||
**Date:** _______________
|
||||
**Build:** `cargo build` commit hash: _______________
|
||||
163
warzone/UAT/PHASE2.md
Normal file
163
warzone/UAT/PHASE2.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Phase 2 — User Acceptance Testing
|
||||
|
||||
> Phase 2 is NOT YET IMPLEMENTED. This is a pre-written test plan.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 1 UAT fully passing
|
||||
- WASM toolchain installed (`wasm-pack`)
|
||||
- Two devices or VMs for multi-device testing
|
||||
|
||||
---
|
||||
|
||||
## 1. WASM Build (Web-CLI Interop)
|
||||
|
||||
```bash
|
||||
cd warzone/crates/warzone-protocol
|
||||
wasm-pack build --target web
|
||||
```
|
||||
|
||||
- [ ] WASM build succeeds
|
||||
- [ ] Web client loads WASM module
|
||||
- [ ] Web client uses X25519 + ChaCha20 (same as CLI)
|
||||
- [ ] Web → CLI: message sent from browser, decrypted by CLI `recv`
|
||||
- [ ] CLI → Web: message sent from CLI, decrypted in browser
|
||||
- [ ] Bidirectional conversation works across web and CLI
|
||||
|
||||
---
|
||||
|
||||
## 2. Delivery Receipts
|
||||
|
||||
**Alice sends to Bob:**
|
||||
- [ ] Alice's UI shows "sent" checkmark (✓) after server accepts
|
||||
- [ ] When Bob's client polls and receives, server generates delivery receipt
|
||||
- [ ] Alice's UI updates to "delivered" (✓✓)
|
||||
- [ ] When Bob reads/decrypts, Bob's client sends read receipt
|
||||
- [ ] Alice's UI updates to "read" (✓✓ blue/colored)
|
||||
|
||||
**Offline Bob:**
|
||||
- [ ] Alice sends while Bob is offline
|
||||
- [ ] "sent" (✓) shown immediately
|
||||
- [ ] Bob comes online, polls → "delivered" (✓✓) on Alice's side
|
||||
- [ ] Receipts themselves are E2E encrypted
|
||||
|
||||
---
|
||||
|
||||
## 3. File Transfer
|
||||
|
||||
**CLI:**
|
||||
```
|
||||
/file /path/to/document.pdf
|
||||
```
|
||||
|
||||
- [ ] File is chunked, encrypted, and sent
|
||||
- [ ] Recipient sees "[file: document.pdf (1.2 MB)]"
|
||||
- [ ] `/save` or auto-download saves to disk
|
||||
- [ ] File integrity check (hash matches)
|
||||
- [ ] Files up to 10 MB work
|
||||
- [ ] Progress shown during transfer
|
||||
|
||||
**Web:**
|
||||
- [ ] File upload button in chat
|
||||
- [ ] File encrypted and sent
|
||||
- [ ] Recipient gets download link
|
||||
- [ ] Downloaded file is correct
|
||||
|
||||
---
|
||||
|
||||
## 4. Multi-Device
|
||||
|
||||
**Setup: Alice on two devices (same seed):**
|
||||
|
||||
```bash
|
||||
# Device 1
|
||||
cargo run --bin warzone-client -- init
|
||||
# Note mnemonic
|
||||
|
||||
# Device 2
|
||||
WARZONE_HOME=/tmp/alice2 cargo run --bin warzone-client -- recover <mnemonic>
|
||||
WARZONE_HOME=/tmp/alice2 cargo run --bin warzone-client -- register -s http://localhost:7700
|
||||
```
|
||||
|
||||
- [ ] Both devices have same fingerprint
|
||||
- [ ] Bob sends to Alice's fingerprint
|
||||
- [ ] Device 1 receives and decrypts
|
||||
- [ ] Device 2 receives and decrypts (separate session)
|
||||
- [ ] Messages sent from Device 1 are visible on Device 2 (via sync)
|
||||
- [ ] Device list shown on server: `GET /v1/devices/<fingerprint>`
|
||||
|
||||
---
|
||||
|
||||
## 5. Hardware Wallet Delegation
|
||||
|
||||
**Connect Ledger/Trezor:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-client -- hw-delegate
|
||||
```
|
||||
|
||||
- [ ] Detects hardware wallet via USB
|
||||
- [ ] Shows "Sign delegation certificate on device"
|
||||
- [ ] User confirms on hardware wallet
|
||||
- [ ] Session key generated, delegation cert stored
|
||||
- [ ] Subsequent operations use session key (no wallet needed)
|
||||
- [ ] After 30 days, prompts for re-delegation
|
||||
|
||||
**Without hardware wallet (session key only):**
|
||||
|
||||
- [ ] All operations work using cached session key
|
||||
- [ ] No USB prompts during normal chat
|
||||
|
||||
---
|
||||
|
||||
## 6. Group Management
|
||||
|
||||
**Kick member:**
|
||||
```
|
||||
/gkick @troublemaker
|
||||
```
|
||||
|
||||
- [ ] Member removed from group
|
||||
- [ ] Sender Keys rotated for remaining members
|
||||
- [ ] Kicked member can no longer decrypt new messages
|
||||
|
||||
**Leave group:**
|
||||
```
|
||||
/gleave ops
|
||||
```
|
||||
|
||||
- [ ] You are removed
|
||||
- [ ] Remaining members rotate keys
|
||||
|
||||
**Group info:**
|
||||
```
|
||||
/ginfo ops
|
||||
```
|
||||
|
||||
- [ ] Shows: name, creator, member list, creation date
|
||||
|
||||
---
|
||||
|
||||
## 7. Message History Persistence
|
||||
|
||||
- [ ] Close and reopen TUI → previous messages still shown
|
||||
- [ ] History stored in local sled DB
|
||||
- [ ] `/history 50` shows last 50 messages
|
||||
- [ ] History is encrypted at rest (tied to seed)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Feature | Result |
|
||||
|---|---------|--------|
|
||||
| 1 | WASM web-CLI interop | ☐ |
|
||||
| 2 | Delivery receipts | ☐ |
|
||||
| 3 | File transfer | ☐ |
|
||||
| 4 | Multi-device | ☐ |
|
||||
| 5 | Hardware wallet delegation | ☐ |
|
||||
| 6 | Group management | ☐ |
|
||||
| 7 | Message history | ☐ |
|
||||
|
||||
**Tester:** _______________
|
||||
**Date:** _______________
|
||||
128
warzone/UAT/PHASE3.md
Normal file
128
warzone/UAT/PHASE3.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Phase 3 — User Acceptance Testing (Federation & Key Transparency)
|
||||
|
||||
> Phase 3 is NOT YET IMPLEMENTED. This is a pre-written test plan.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 2 UAT fully passing
|
||||
- Two warzone-server instances on different domains
|
||||
- DNS zone control for both domains
|
||||
|
||||
---
|
||||
|
||||
## 1. DNS Server Discovery
|
||||
|
||||
**Setup TXT record:**
|
||||
```
|
||||
_warzone._tcp.a1.example.com TXT "v=wz1; endpoint=https://wz.a1.example.com; pubkey=base64..."
|
||||
```
|
||||
|
||||
**Test discovery:**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- discover a1.example.com
|
||||
```
|
||||
|
||||
- [ ] Resolves TXT record
|
||||
- [ ] Shows endpoint URL and server public key
|
||||
- [ ] Server pubkey pinned on first contact (TOFU)
|
||||
|
||||
---
|
||||
|
||||
## 2. DNS Key Transparency
|
||||
|
||||
**Publish key to DNS:**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- publish-key --domain a1.example.com
|
||||
```
|
||||
|
||||
- [ ] TXT record created: `manwe.a1.example.com TXT "v=wz1; fp=...; pubkey=...; sig=..."`
|
||||
- [ ] Self-signature is valid
|
||||
- [ ] Only server's delegated zone is modified
|
||||
|
||||
**Verify key via DNS:**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- verify-key @manwe.a1.example.com
|
||||
```
|
||||
|
||||
- [ ] Fetches TXT record
|
||||
- [ ] Verifies self-signature
|
||||
- [ ] Compares against server-provided key
|
||||
- [ ] Match → "Key verified via DNS"
|
||||
- [ ] Mismatch → "WARNING: server may be performing MITM"
|
||||
- [ ] No DNS record → "Falling back to TOFU"
|
||||
|
||||
---
|
||||
|
||||
## 3. Federated Messaging
|
||||
|
||||
**Server A (a1.example.com) and Server B (b1.example.com):**
|
||||
|
||||
Alice is on Server A, Bob is on Server B.
|
||||
|
||||
**Alice sends to Bob:**
|
||||
```
|
||||
/dm @bob.b1.example.com hello from server A!
|
||||
```
|
||||
|
||||
- [ ] Client resolves `b1.example.com` via DNS
|
||||
- [ ] Fetches Bob's bundle from Server B
|
||||
- [ ] X3DH + Ratchet encrypt
|
||||
- [ ] Message sent via Server A → Server B relay
|
||||
- [ ] Bob receives on Server B
|
||||
- [ ] Bob decrypts successfully
|
||||
|
||||
**Bob replies:**
|
||||
```
|
||||
/dm @alice.a1.example.com hey alice!
|
||||
```
|
||||
|
||||
- [ ] Reverse path works (B → A)
|
||||
- [ ] Existing ratchet session reused
|
||||
|
||||
---
|
||||
|
||||
## 4. Server-to-Server Mutual TLS
|
||||
|
||||
- [ ] Server A connects to Server B with TLS
|
||||
- [ ] Both servers verify each other's pubkey (from DNS TXT)
|
||||
- [ ] Invalid server pubkey → connection refused
|
||||
- [ ] Man-in-the-middle between servers → TLS fails
|
||||
|
||||
---
|
||||
|
||||
## 5. Gossip Peer Discovery
|
||||
|
||||
**Server A knows Server B. Server C joins:**
|
||||
|
||||
- [ ] Server C registers with Server A
|
||||
- [ ] Server A gossips Server C's endpoint to Server B
|
||||
- [ ] Server B can now route messages to Server C users
|
||||
- [ ] No manual configuration needed on Server B
|
||||
|
||||
---
|
||||
|
||||
## 6. Hard-coded Peer List (DNS Fallback)
|
||||
|
||||
**DNS is down:**
|
||||
```bash
|
||||
cargo run --bin warzone-server -- --peers "https://wz.b1.example.com,https://wz.c1.example.com"
|
||||
```
|
||||
|
||||
- [ ] Server connects to listed peers directly
|
||||
- [ ] Federated messaging works without DNS
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Feature | Result |
|
||||
|---|---------|--------|
|
||||
| 1 | DNS server discovery | ☐ |
|
||||
| 2 | DNS key transparency | ☐ |
|
||||
| 3 | Federated messaging | ☐ |
|
||||
| 4 | Server mutual TLS | ☐ |
|
||||
| 5 | Gossip peer discovery | ☐ |
|
||||
| 6 | Hard-coded peer fallback | ☐ |
|
||||
|
||||
**Tester:** _______________
|
||||
**Date:** _______________
|
||||
157
warzone/UAT/PHASE4.md
Normal file
157
warzone/UAT/PHASE4.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Phase 4 — User Acceptance Testing (Warzone Delivery / Mule Protocol)
|
||||
|
||||
> Phase 4 is NOT YET IMPLEMENTED. This is a pre-written test plan.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 3 UAT fully passing
|
||||
- Two isolated networks (can use VMs or Docker networks)
|
||||
- A device that can move between networks (the mule)
|
||||
|
||||
---
|
||||
|
||||
## 1. Mule Identity & Authorization
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- init
|
||||
cargo run --bin warzone-mule -- register -s http://server-a:7700
|
||||
```
|
||||
|
||||
- [ ] Mule generates its own identity
|
||||
- [ ] Mule registered with Server A
|
||||
- [ ] Server admin authorizes mule: `warzone-server admin authorize-mule <mule-fp>`
|
||||
- [ ] Unauthorized mule rejected on pickup attempt
|
||||
|
||||
---
|
||||
|
||||
## 2. Message Pickup
|
||||
|
||||
**Server A has queued messages for users on Server B (which is offline):**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- pickup -s http://server-a:7700
|
||||
```
|
||||
|
||||
- [ ] Mule connects to Server A
|
||||
- [ ] Mule authenticates (challenge-response)
|
||||
- [ ] Server returns queued outbound messages (encrypted blobs)
|
||||
- [ ] Messages marked as "IN_TRANSIT by mule X" on Server A
|
||||
- [ ] Mule stores messages locally
|
||||
- [ ] Mule reports capacity: "Picked up 42 messages (1.2 MB / 50 MB capacity)"
|
||||
|
||||
---
|
||||
|
||||
## 3. Physical Transport & Delivery
|
||||
|
||||
**Mule moves to Server B's network:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- deliver -s http://server-b:7700
|
||||
```
|
||||
|
||||
- [ ] Mule connects to Server B
|
||||
- [ ] Delivers encrypted blobs
|
||||
- [ ] Server B queues messages for local recipients
|
||||
- [ ] Server B returns delivery receipts (signed)
|
||||
- [ ] Mule stores receipts locally
|
||||
|
||||
---
|
||||
|
||||
## 4. Receipt Delivery
|
||||
|
||||
**Mule returns to Server A's network:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- receipts -s http://server-a:7700
|
||||
```
|
||||
|
||||
- [ ] Mule delivers receipts to Server A
|
||||
- [ ] Server A marks messages as DELIVERED
|
||||
- [ ] Server A removes messages from outbound queue
|
||||
|
||||
---
|
||||
|
||||
## 5. Receipt Enforcement
|
||||
|
||||
**Mule tries to pick up again WITHOUT delivering previous receipts:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- pickup -s http://server-a:7700
|
||||
```
|
||||
|
||||
- [ ] Server A rejects: "outstanding receipts not delivered"
|
||||
- [ ] Mule must deliver receipts first (or submit signed failure report)
|
||||
|
||||
---
|
||||
|
||||
## 6. Deduplication
|
||||
|
||||
**Two mules pick up the same messages:**
|
||||
|
||||
- [ ] Mule 1 picks up and delivers to Server B
|
||||
- [ ] Mule 2 picks up same messages (still in transit)
|
||||
- [ ] Mule 2 delivers to Server B
|
||||
- [ ] Server B deduplicates: messages delivered once, no duplicates for recipients
|
||||
|
||||
---
|
||||
|
||||
## 7. Message Expiry
|
||||
|
||||
**Messages older than TTL:**
|
||||
|
||||
- [ ] Server queues message with 7-day TTL
|
||||
- [ ] After 7 days without pickup → status changes to EXPIRED
|
||||
- [ ] Expired messages not given to mules
|
||||
- [ ] Expired messages cleaned up from DB
|
||||
|
||||
---
|
||||
|
||||
## 8. Outer Encryption (Metadata Hiding)
|
||||
|
||||
- [ ] Messages from Server A to Server B wrapped in outer encryption (Server B's pubkey)
|
||||
- [ ] Mule sees only: "encrypted blob for Server B"
|
||||
- [ ] Mule cannot see sender/recipient fingerprints
|
||||
- [ ] Server B unwraps outer layer, routes inner messages to recipients
|
||||
|
||||
---
|
||||
|
||||
## 9. Partial Sync / Resume
|
||||
|
||||
**Mule connection interrupted during pickup:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- pickup -s http://server-a:7700
|
||||
# kill connection mid-transfer
|
||||
cargo run --bin warzone-mule -- pickup -s http://server-a:7700
|
||||
```
|
||||
|
||||
- [ ] Second pickup resumes from where it left off
|
||||
- [ ] No duplicate messages in mule's local store
|
||||
|
||||
---
|
||||
|
||||
## 10. Compression
|
||||
|
||||
- [ ] Message bundles compressed with zstd before transfer
|
||||
- [ ] Mule reports compressed size: "42 messages: 1.2 MB → 400 KB (67% compression)"
|
||||
- [ ] Decompression on delivery
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Feature | Result |
|
||||
|---|---------|--------|
|
||||
| 1 | Mule identity & auth | ☐ |
|
||||
| 2 | Message pickup | ☐ |
|
||||
| 3 | Physical delivery | ☐ |
|
||||
| 4 | Receipt delivery | ☐ |
|
||||
| 5 | Receipt enforcement | ☐ |
|
||||
| 6 | Deduplication | ☐ |
|
||||
| 7 | Message expiry | ☐ |
|
||||
| 8 | Outer encryption | ☐ |
|
||||
| 9 | Partial sync | ☐ |
|
||||
| 10 | Compression | ☐ |
|
||||
|
||||
**Tester:** _______________
|
||||
**Date:** _______________
|
||||
148
warzone/UAT/PHASE5.md
Normal file
148
warzone/UAT/PHASE5.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Phase 5 — User Acceptance Testing (Transport Fallbacks)
|
||||
|
||||
> Phase 5 is NOT YET IMPLEMENTED. This is a pre-written test plan.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 4 UAT fully passing
|
||||
- Bluetooth-capable devices
|
||||
- LoRa hardware (e.g. Heltec ESP32 LoRa, RAK WisBlock)
|
||||
- Two devices on same Wi-Fi for Wi-Fi Direct testing
|
||||
|
||||
---
|
||||
|
||||
## 1. Bluetooth Mule Transfer
|
||||
|
||||
**Mule device (phone/laptop) near Server A:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- pickup --transport bluetooth
|
||||
```
|
||||
|
||||
- [ ] Mule scans for nearby warzone-server via BLE advertisement
|
||||
- [ ] Connects via Bluetooth Classic (RFCOMM)
|
||||
- [ ] Picks up messages (same protocol as HTTP, different transport)
|
||||
- [ ] Transfer speed reasonable (> 100 KB/s)
|
||||
|
||||
**Mule near Server B:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- deliver --transport bluetooth
|
||||
```
|
||||
|
||||
- [ ] Delivers messages via Bluetooth
|
||||
- [ ] Receipts returned
|
||||
|
||||
---
|
||||
|
||||
## 2. LoRa Transport (Emergency)
|
||||
|
||||
**Setup two LoRa nodes with warzone-mule:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- lora-beacon --freq 868.0
|
||||
```
|
||||
|
||||
- [ ] Device broadcasts presence beacon (< 50 bytes)
|
||||
- [ ] Nearby LoRa node detects beacon
|
||||
|
||||
**Send short text over LoRa:**
|
||||
```bash
|
||||
cargo run --bin warzone-mule -- lora-send "SOS need evac" --to <fingerprint>
|
||||
```
|
||||
|
||||
- [ ] Message fits in single LoRa packet (< 250 bytes)
|
||||
- [ ] Compact binary format used (not JSON)
|
||||
- [ ] Recipient receives and decrypts
|
||||
- [ ] Delivery receipt sent back over LoRa
|
||||
|
||||
**LoRa limitations:**
|
||||
- [ ] Messages > 200 chars rejected with warning
|
||||
- [ ] Files cannot be sent over LoRa
|
||||
- [ ] Latency shown: "Sent via LoRa (estimated 2-5 seconds)"
|
||||
|
||||
---
|
||||
|
||||
## 3. mDNS / LAN Discovery
|
||||
|
||||
**Two devices on same LAN, no internet:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-server -- --mdns
|
||||
```
|
||||
|
||||
- [ ] Server advertises via mDNS: `_warzone._tcp.local`
|
||||
- [ ] Client discovers server without typing IP/URL:
|
||||
```bash
|
||||
cargo run --bin warzone-client -- chat --discover
|
||||
```
|
||||
- [ ] Shows: "Found warzone server at 192.168.1.42:7700"
|
||||
- [ ] Chat works normally over LAN
|
||||
|
||||
---
|
||||
|
||||
## 4. Wi-Fi Direct (Nearby Mesh)
|
||||
|
||||
**Two devices, no router needed:**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-client -- chat --wifi-direct
|
||||
```
|
||||
|
||||
- [ ] Devices discover each other via Wi-Fi Direct
|
||||
- [ ] Form ad-hoc connection
|
||||
- [ ] Messages synced peer-to-peer (no server)
|
||||
- [ ] Group sync: all messages replicated to all peers in range
|
||||
- [ ] Bandwidth: > 10 MB/s
|
||||
|
||||
---
|
||||
|
||||
## 5. USB / Sneakernet Export
|
||||
|
||||
**Export messages:**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- export --since 24h --to /mnt/usb/messages.wz
|
||||
```
|
||||
|
||||
- [ ] Messages exported as encrypted file
|
||||
- [ ] File is portable (copy to USB drive)
|
||||
- [ ] Export size shown: "Exported 142 messages (2.3 MB)"
|
||||
|
||||
**Import on another machine:**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- import /mnt/usb/messages.wz
|
||||
```
|
||||
|
||||
- [ ] Messages imported and decrypted
|
||||
- [ ] Deduplication: already-seen messages skipped
|
||||
- [ ] "Imported 142 messages (38 new)"
|
||||
|
||||
---
|
||||
|
||||
## 6. Transport Fallback Priority
|
||||
|
||||
**Configure fallback chain:**
|
||||
```
|
||||
warzone-server --transport https,bluetooth,lora
|
||||
```
|
||||
|
||||
- [ ] Server tries HTTPS first
|
||||
- [ ] If HTTPS fails → falls back to Bluetooth
|
||||
- [ ] If Bluetooth unavailable → falls back to LoRa
|
||||
- [ ] Each fallback logged with reason
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Feature | Result |
|
||||
|---|---------|--------|
|
||||
| 1 | Bluetooth mule | ☐ |
|
||||
| 2 | LoRa transport | ☐ |
|
||||
| 3 | mDNS discovery | ☐ |
|
||||
| 4 | Wi-Fi Direct | ☐ |
|
||||
| 5 | USB export/import | ☐ |
|
||||
| 6 | Transport fallback | ☐ |
|
||||
|
||||
**Tester:** _______________
|
||||
**Date:** _______________
|
||||
73
warzone/UAT/PHASE6.md
Normal file
73
warzone/UAT/PHASE6.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Phase 6 — User Acceptance Testing (Metadata Protection)
|
||||
|
||||
> Phase 6 is NOT YET IMPLEMENTED. This is a pre-written test plan.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 5 UAT fully passing
|
||||
- Network traffic analysis tools (Wireshark/tcpdump)
|
||||
- At least 3 federated servers for onion routing
|
||||
|
||||
---
|
||||
|
||||
## 1. Sealed Sender
|
||||
|
||||
**Alice sends to Bob through server:**
|
||||
|
||||
- [ ] Server receives message with recipient fingerprint but NO sender fingerprint
|
||||
- [ ] Server logs show: "Message for <bob-fp> from [sealed]"
|
||||
- [ ] Bob decrypts and sees Alice's identity (embedded in ciphertext)
|
||||
- [ ] Wireshark: server-bound traffic contains no sender metadata
|
||||
|
||||
**Server admin inspects DB:**
|
||||
- [ ] Message queue shows `to` field only, no `from`
|
||||
- [ ] Cannot determine who sent the message
|
||||
|
||||
---
|
||||
|
||||
## 2. Traffic Analysis Resistance
|
||||
|
||||
**Padding:**
|
||||
- [ ] All messages padded to fixed sizes (256, 1024, 4096 bytes)
|
||||
- [ ] Small "hi" and large paragraph produce same-size ciphertext on wire
|
||||
- [ ] Wireshark confirms uniform packet sizes
|
||||
|
||||
**Timing:**
|
||||
- [ ] Messages not sent immediately — random delay (0-2 seconds)
|
||||
- [ ] Constant-rate dummy traffic when idle (configurable)
|
||||
- [ ] Observer cannot distinguish real messages from dummy traffic
|
||||
|
||||
---
|
||||
|
||||
## 3. Onion Routing (Opt-in)
|
||||
|
||||
**Setup: 3 servers (A, B, C). Alice on A, Bob on C.**
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-client -- chat @bob.c.example.com --onion
|
||||
```
|
||||
|
||||
- [ ] Client builds onion route: A → B → C
|
||||
- [ ] Message encrypted in 3 layers: encrypt(C, encrypt(B, encrypt(A, plaintext)))
|
||||
- [ ] Server A sees: "message for Server B" (doesn't know final destination)
|
||||
- [ ] Server B sees: "message for Server C" (doesn't know origin)
|
||||
- [ ] Server C sees: "message for Bob" (doesn't know it went through A and B)
|
||||
- [ ] Bob decrypts successfully
|
||||
- [ ] Latency: shown as "onion: 3 hops, ~500ms"
|
||||
|
||||
**Onion routing disabled (default):**
|
||||
- [ ] Direct routing: A → C (faster, less privacy)
|
||||
- [ ] No onion overhead
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Feature | Result |
|
||||
|---|---------|--------|
|
||||
| 1 | Sealed sender | ☐ |
|
||||
| 2 | Traffic analysis resistance | ☐ |
|
||||
| 3 | Onion routing | ☐ |
|
||||
|
||||
**Tester:** _______________
|
||||
**Date:** _______________
|
||||
174
warzone/UAT/PHASE7.md
Normal file
174
warzone/UAT/PHASE7.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Phase 7 — User Acceptance Testing (Operations & Polish)
|
||||
|
||||
> Phase 7 is NOT YET IMPLEMENTED. This is a pre-written test plan.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 6 UAT fully passing
|
||||
- ntfy server (self-hosted or ntfy.sh)
|
||||
- CI/CD pipeline configured
|
||||
|
||||
---
|
||||
|
||||
## 1. ntfy Push Notifications
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
cargo run --bin warzone-server -- --ntfy-url https://ntfy.example.com
|
||||
```
|
||||
|
||||
**Client subscribes:**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- notifications --enable
|
||||
```
|
||||
|
||||
- [ ] Client registers ntfy topic (fingerprint-derived)
|
||||
- [ ] When offline and message arrives, ntfy push notification sent
|
||||
- [ ] Notification shows: "New message" (NO message content — E2E)
|
||||
- [ ] Android: ntfy app shows notification
|
||||
- [ ] iOS: ntfy app shows notification
|
||||
- [ ] Desktop: ntfy web shows notification
|
||||
- [ ] Self-hosted ntfy: all above work against own instance
|
||||
|
||||
---
|
||||
|
||||
## 2. DNS-over-HTTPS (Censored Networks)
|
||||
|
||||
**DNS blocked but HTTPS available:**
|
||||
```bash
|
||||
cargo run --bin warzone-client -- chat --doh https://1.1.1.1/dns-query
|
||||
```
|
||||
|
||||
- [ ] DNS resolution via HTTPS (bypasses local DNS censorship)
|
||||
- [ ] Federation discovery works through DoH
|
||||
- [ ] Key transparency verification works through DoH
|
||||
- [ ] Fallback to system DNS if DoH fails
|
||||
|
||||
---
|
||||
|
||||
## 3. Server-at-Rest Encryption
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-server -- --encrypt-db
|
||||
# Prompted for passphrase on startup
|
||||
```
|
||||
|
||||
- [ ] sled database encrypted at rest
|
||||
- [ ] Server restart requires passphrase
|
||||
- [ ] If server seized (power off), DB is unreadable without passphrase
|
||||
- [ ] Performance impact: < 10% overhead
|
||||
- [ ] Without `--encrypt-db`, DB is plaintext (default)
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin CLI
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-server -- admin
|
||||
```
|
||||
|
||||
- [ ] `admin list-users` — shows all registered fingerprints + aliases
|
||||
- [ ] `admin list-groups` — shows all groups + member counts
|
||||
- [ ] `admin ban <fingerprint>` — blocks user from server
|
||||
- [ ] `admin unban <fingerprint>` — unblocks user
|
||||
- [ ] `admin list-mules` — shows authorized mules
|
||||
- [ ] `admin authorize-mule <fp>` — authorizes a mule
|
||||
- [ ] `admin revoke-mule <fp>` — revokes mule authorization
|
||||
- [ ] `admin stats` — shows message counts, active users, queue depth
|
||||
- [ ] `admin gc` — garbage collect expired messages, tokens, aliases
|
||||
|
||||
---
|
||||
|
||||
## 5. Rate Limiting
|
||||
|
||||
**Spam prevention:**
|
||||
- [ ] More than 100 messages/minute from one fingerprint → rate limited
|
||||
- [ ] Rate limit response: HTTP 429 with retry-after header
|
||||
- [ ] Client shows: "Rate limited, retry in 30 seconds"
|
||||
- [ ] Group sends: limit per-member, not per-group
|
||||
|
||||
**Registration abuse:**
|
||||
- [ ] More than 5 identities from one IP per hour → blocked
|
||||
- [ ] Alias registration: max 1 per hour per fingerprint
|
||||
|
||||
---
|
||||
|
||||
## 6. Audit Logging
|
||||
|
||||
```bash
|
||||
cargo run --bin warzone-server -- --audit-log /var/log/warzone-audit.log
|
||||
```
|
||||
|
||||
- [ ] All authentication events logged (success + failure)
|
||||
- [ ] Key registrations logged
|
||||
- [ ] Group create/join/leave logged
|
||||
- [ ] Alias registrations logged
|
||||
- [ ] Message metadata logged (from_fp, to_fp, timestamp, size — NO content)
|
||||
- [ ] Mule pickups/deliveries logged
|
||||
- [ ] Log format: structured JSON, one event per line
|
||||
- [ ] Log rotation compatible (logrotate)
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-Compilation CI
|
||||
|
||||
```bash
|
||||
cargo build --target x86_64-unknown-linux-gnu
|
||||
cargo build --target aarch64-unknown-linux-gnu
|
||||
cargo build --target x86_64-apple-darwin
|
||||
cargo build --target aarch64-apple-darwin
|
||||
cargo build --target x86_64-pc-windows-msvc
|
||||
wasm-pack build --target web crates/warzone-protocol
|
||||
```
|
||||
|
||||
- [ ] Linux x86_64: static binary, runs on Ubuntu/Debian/Alpine
|
||||
- [ ] Linux aarch64 (ARM): runs on Raspberry Pi / ARM servers
|
||||
- [ ] macOS x86_64: runs on Intel Macs
|
||||
- [ ] macOS aarch64: runs on Apple Silicon
|
||||
- [ ] Windows: runs on Windows 10+
|
||||
- [ ] WASM: loads in Chrome, Firefox, Safari
|
||||
- [ ] All binaries < 20 MB
|
||||
- [ ] CI pipeline runs tests on all platforms
|
||||
- [ ] Release artifacts uploaded to GitHub/Gitea
|
||||
|
||||
---
|
||||
|
||||
## 8. Monitoring & Health
|
||||
|
||||
**Health check:**
|
||||
```bash
|
||||
curl http://localhost:7700/v1/health
|
||||
```
|
||||
|
||||
- [ ] Returns status, version, uptime
|
||||
- [ ] Queue depth included
|
||||
- [ ] Active connections count
|
||||
- [ ] DB size on disk
|
||||
|
||||
**Prometheus metrics (optional):**
|
||||
```bash
|
||||
curl http://localhost:7700/metrics
|
||||
```
|
||||
|
||||
- [ ] `warzone_messages_total` counter
|
||||
- [ ] `warzone_active_users` gauge
|
||||
- [ ] `warzone_queue_depth` gauge
|
||||
- [ ] `warzone_auth_failures_total` counter
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Feature | Result |
|
||||
|---|---------|--------|
|
||||
| 1 | ntfy notifications | ☐ |
|
||||
| 2 | DNS-over-HTTPS | ☐ |
|
||||
| 3 | Server-at-rest encryption | ☐ |
|
||||
| 4 | Admin CLI | ☐ |
|
||||
| 5 | Rate limiting | ☐ |
|
||||
| 6 | Audit logging | ☐ |
|
||||
| 7 | Cross-compilation CI | ☐ |
|
||||
| 8 | Monitoring & health | ☐ |
|
||||
|
||||
**Tester:** _______________
|
||||
**Date:** _______________
|
||||
12
warzone/bots.example.json
Normal file
12
warzone/bots.example.json
Normal 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"}
|
||||
]
|
||||
@@ -21,3 +21,13 @@ chacha20poly1305.workspace = true
|
||||
rand.workspace = true
|
||||
zeroize.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
x25519-dalek.workspace = true
|
||||
bincode.workspace = true
|
||||
sha2.workspace = true
|
||||
libc = "0.2"
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
|
||||
@@ -1,16 +1 @@
|
||||
use crate::keystore;
|
||||
|
||||
pub fn run() -> anyhow::Result<()> {
|
||||
let seed = keystore::load_seed()?;
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
|
||||
println!("Fingerprint: {}", pub_id.fingerprint);
|
||||
println!("Signing key: {}", hex::encode(pub_id.signing.as_bytes()));
|
||||
println!(
|
||||
"Encryption key: {}",
|
||||
hex::encode(pub_id.encryption.as_bytes())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// Info is now handled directly in main.rs with the pre-unlocked identity.
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use anyhow::Result;
|
||||
use warzone_protocol::identity::Seed;
|
||||
use warzone_protocol::prekey::{
|
||||
generate_one_time_pre_keys, generate_signed_pre_key, OneTimePreKeyPublic, PreKeyBundle,
|
||||
};
|
||||
|
||||
use crate::keystore;
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
pub fn run() -> anyhow::Result<()> {
|
||||
pub fn run() -> Result<()> {
|
||||
let seed = Seed::generate();
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
@@ -21,7 +27,75 @@ pub fn run() -> anyhow::Result<()> {
|
||||
|
||||
// Save encrypted seed
|
||||
keystore::save_seed(&seed)?;
|
||||
println!("Seed saved to ~/.warzone/identity.seed");
|
||||
println!("Seed saved to {}", keystore::data_dir().join("identity.seed").display());
|
||||
|
||||
// Generate pre-keys and store secrets locally
|
||||
let db = LocalDb::open()?;
|
||||
|
||||
let (spk_secret, signed_pre_key) = generate_signed_pre_key(&identity, 1);
|
||||
db.save_signed_pre_key(1, &spk_secret)?;
|
||||
|
||||
let otpks = generate_one_time_pre_keys(0, 10);
|
||||
for otpk in &otpks {
|
||||
db.save_one_time_pre_key(otpk.id, &otpk.secret)?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"Generated 1 signed pre-key + {} one-time pre-keys",
|
||||
otpks.len()
|
||||
);
|
||||
|
||||
// Build bundle for server registration
|
||||
let bundle = PreKeyBundle {
|
||||
identity_key: *pub_id.signing.as_bytes(),
|
||||
identity_encryption_key: *pub_id.encryption.as_bytes(),
|
||||
signed_pre_key,
|
||||
one_time_pre_key: Some(OneTimePreKeyPublic {
|
||||
id: otpks[0].id,
|
||||
public_key: *otpks[0].public.as_bytes(),
|
||||
}),
|
||||
};
|
||||
|
||||
// Store bundle locally for later registration
|
||||
let bundle_bytes = bincode::serialize(&bundle)?;
|
||||
let bundle_path = crate::keystore::data_dir().join("bundle.bin");
|
||||
std::fs::write(&bundle_path, &bundle_bytes)?;
|
||||
|
||||
println!("\nTo register with a server, run:");
|
||||
println!(
|
||||
" warzone send <recipient-fingerprint> <message> -s http://server:7700"
|
||||
);
|
||||
println!("\nOr register your key bundle manually:");
|
||||
println!(" (bundle auto-registered on first send)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register the local bundle with a server using an already-unlocked identity.
|
||||
pub async fn register_with_server_identity(
|
||||
server_url: &str,
|
||||
identity: &warzone_protocol::identity::IdentityKeyPair,
|
||||
) -> Result<()> {
|
||||
let pub_id = identity.public_identity();
|
||||
let fp = pub_id.fingerprint.to_string();
|
||||
|
||||
let bundle_path = crate::keystore::data_dir().join("bundle.bin");
|
||||
|
||||
let bundle_bytes = std::fs::read(&bundle_path)
|
||||
.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, eth_address).await?;
|
||||
println!("Bundle registered with {}", server_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod info;
|
||||
pub mod init;
|
||||
pub mod recover;
|
||||
pub mod send;
|
||||
pub mod recv;
|
||||
|
||||
142
warzone/crates/warzone-client/src/cli/recv.rs
Normal file
142
warzone/crates/warzone-client/src/cli/recv.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use anyhow::{Context, Result};
|
||||
use warzone_protocol::identity::IdentityKeyPair;
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
use warzone_protocol::x3dh;
|
||||
use x25519_dalek::PublicKey;
|
||||
|
||||
use warzone_protocol::message::WireMessage;
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> {
|
||||
let our_pub = identity.public_identity();
|
||||
let our_fp = our_pub.fingerprint.to_string();
|
||||
let db = LocalDb::open()?;
|
||||
let client = ServerClient::new(server_url);
|
||||
|
||||
println!("Polling for messages as {}...", our_fp);
|
||||
|
||||
let messages = client.poll_messages(&our_fp).await?;
|
||||
|
||||
if messages.is_empty() {
|
||||
println!("No new messages.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Received {} message(s):\n", messages.len());
|
||||
|
||||
for raw in &messages {
|
||||
match bincode::deserialize::<WireMessage>(raw) {
|
||||
Ok(WireMessage::KeyExchange {
|
||||
id: _,
|
||||
sender_fingerprint,
|
||||
sender_identity_encryption_key,
|
||||
ephemeral_public,
|
||||
used_one_time_pre_key_id,
|
||||
ratchet_message,
|
||||
}) => {
|
||||
let sender_fp = Fingerprint::from_hex(&sender_fingerprint)
|
||||
.context("invalid sender fingerprint")?;
|
||||
|
||||
// Load our signed pre-key secret
|
||||
let spk_id = 1u32; // We use ID 1 for our signed pre-key
|
||||
let spk_secret = db
|
||||
.load_signed_pre_key(spk_id)?
|
||||
.context("missing signed pre-key — run `warzone init` first")?;
|
||||
|
||||
// Load one-time pre-key if used
|
||||
let otpk_secret = if let Some(id) = used_one_time_pre_key_id {
|
||||
db.take_one_time_pre_key(id)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// X3DH respond
|
||||
let their_identity_x25519 = PublicKey::from(sender_identity_encryption_key);
|
||||
let their_ephemeral = PublicKey::from(ephemeral_public);
|
||||
|
||||
let shared_secret = x3dh::respond(
|
||||
&identity,
|
||||
&spk_secret,
|
||||
otpk_secret.as_ref(),
|
||||
&their_identity_x25519,
|
||||
&their_ephemeral,
|
||||
)
|
||||
.context("X3DH respond failed")?;
|
||||
|
||||
// Init ratchet as Bob
|
||||
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
|
||||
|
||||
// Decrypt the message
|
||||
match state.decrypt(&ratchet_message) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext);
|
||||
println!(" [{}] {}: {}", "new session", sender_fingerprint, text);
|
||||
db.save_session(&sender_fp, &state)?;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" [{}] decrypt failed: {}", sender_fingerprint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(WireMessage::Message {
|
||||
id: _,
|
||||
sender_fingerprint,
|
||||
ratchet_message,
|
||||
}) => {
|
||||
let sender_fp = Fingerprint::from_hex(&sender_fingerprint)
|
||||
.context("invalid sender fingerprint")?;
|
||||
|
||||
match db.load_session(&sender_fp)? {
|
||||
Some(mut state) => match state.decrypt(&ratchet_message) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext);
|
||||
println!(" {}: {}", sender_fingerprint, text);
|
||||
db.save_session(&sender_fp, &state)?;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" [{}] decrypt failed: {}", sender_fingerprint, e);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
eprintln!(
|
||||
" [{}] no session — cannot decrypt (need key exchange first)",
|
||||
sender_fingerprint
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(WireMessage::Receipt {
|
||||
sender_fingerprint,
|
||||
message_id,
|
||||
receipt_type,
|
||||
}) => {
|
||||
println!(
|
||||
" [receipt] {} acknowledged message {} ({:?})",
|
||||
sender_fingerprint, message_id, receipt_type
|
||||
);
|
||||
}
|
||||
Ok(WireMessage::FileHeader { filename, sender_fingerprint, file_size, .. }) => {
|
||||
println!(" [file header] {} is sending '{}' ({} bytes)", sender_fingerprint, filename, file_size);
|
||||
}
|
||||
Ok(WireMessage::FileChunk { filename, chunk_index, total_chunks, sender_fingerprint, .. }) => {
|
||||
println!(" [file chunk] {} chunk {}/{} of '{}'", sender_fingerprint, chunk_index + 1, total_chunks, filename);
|
||||
}
|
||||
Ok(WireMessage::GroupSenderKey { sender_fingerprint, group_name, .. }) => {
|
||||
println!(" [group] {} sent to #{}", sender_fingerprint, group_name);
|
||||
}
|
||||
Ok(WireMessage::SenderKeyDistribution { sender_fingerprint, group_name, .. }) => {
|
||||
println!(" [sender key] received key from {} for #{}", sender_fingerprint, group_name);
|
||||
}
|
||||
Ok(WireMessage::CallSignal { sender_fingerprint, signal_type, target, .. }) => {
|
||||
println!(" [call] {:?} from {} → {}", signal_type, sender_fingerprint, target);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" failed to deserialize message: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
72
warzone/crates/warzone-client/src/cli/send.rs
Normal file
72
warzone/crates/warzone-client/src/cli/send.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use anyhow::{Context, Result};
|
||||
use warzone_protocol::identity::IdentityKeyPair;
|
||||
use warzone_protocol::message::WireMessage;
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
use warzone_protocol::x3dh;
|
||||
use x25519_dalek::PublicKey;
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
pub async fn run(recipient_fp: &str, message: &str, server_url: &str, identity: &IdentityKeyPair) -> Result<()> {
|
||||
let our_pub = identity.public_identity();
|
||||
let db = LocalDb::open()?;
|
||||
let client = ServerClient::new(server_url);
|
||||
|
||||
let recipient = Fingerprint::from_hex(recipient_fp)
|
||||
.context("invalid recipient fingerprint")?;
|
||||
|
||||
// Check for existing session
|
||||
let mut ratchet = db.load_session(&recipient)?;
|
||||
|
||||
let wire_msg = if let Some(ref mut state) = ratchet {
|
||||
// Existing session — just encrypt with ratchet
|
||||
let encrypted = state.encrypt(message.as_bytes())
|
||||
.context("ratchet encrypt failed")?;
|
||||
db.save_session(&recipient, state)?;
|
||||
|
||||
WireMessage::Message {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||
ratchet_message: encrypted,
|
||||
}
|
||||
} else {
|
||||
// No session — perform X3DH key exchange
|
||||
println!("No existing session. Fetching key bundle for {}...", recipient_fp);
|
||||
|
||||
let bundle = client.fetch_bundle(recipient_fp).await
|
||||
.context("failed to fetch recipient's bundle. Are they registered?")?;
|
||||
|
||||
let x3dh_result = x3dh::initiate(&identity, &bundle)
|
||||
.context("X3DH key exchange failed")?;
|
||||
|
||||
// Init ratchet as Alice
|
||||
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
|
||||
let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
|
||||
|
||||
let encrypted = state.encrypt(message.as_bytes())
|
||||
.context("ratchet encrypt failed")?;
|
||||
|
||||
// Save session
|
||||
db.save_session(&recipient, &state)?;
|
||||
|
||||
WireMessage::KeyExchange {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
||||
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
|
||||
ratchet_message: encrypted,
|
||||
}
|
||||
};
|
||||
|
||||
// Serialize and send
|
||||
let encoded = bincode::serialize(&wire_msg)
|
||||
.context("failed to serialize wire message")?;
|
||||
|
||||
client.send_message(recipient_fp, Some(&our_pub.fingerprint.to_string()), &encoded).await?;
|
||||
|
||||
println!("Message sent to {}", recipient_fp);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,40 +1,177 @@
|
||||
//! Seed storage: encrypts at rest with Argon2 + ChaCha20-Poly1305.
|
||||
//! For Phase 1, we store the seed in plaintext. Encryption is TODO.
|
||||
//! Seed storage: encrypted at rest with Argon2id + ChaCha20-Poly1305.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use argon2::Argon2;
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
ChaCha20Poly1305, Nonce,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use warzone_protocol::identity::Seed;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
fn seed_path() -> PathBuf {
|
||||
/// Magic bytes to identify encrypted seed files.
|
||||
const MAGIC: &[u8; 4] = b"WZS1";
|
||||
/// Salt length for Argon2.
|
||||
const SALT_LEN: usize = 16;
|
||||
/// Nonce length for ChaCha20-Poly1305.
|
||||
const NONCE_LEN: usize = 12;
|
||||
|
||||
/// Get the warzone data directory. Respects WARZONE_HOME env var,
|
||||
/// falls back to ~/.warzone.
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if let Ok(wz) = std::env::var("WARZONE_HOME") {
|
||||
PathBuf::from(wz)
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".warzone").join("identity.seed")
|
||||
PathBuf::from(home).join(".warzone")
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_path() -> PathBuf {
|
||||
data_dir().join("identity.seed")
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key from a passphrase using Argon2id.
|
||||
fn derive_key(passphrase: &[u8], salt: &[u8]) -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(passphrase, salt, &mut key)
|
||||
.expect("Argon2 should not fail with valid params");
|
||||
key
|
||||
}
|
||||
|
||||
/// Prompt for a passphrase (hidden input).
|
||||
fn prompt_passphrase(prompt: &str) -> String {
|
||||
eprint!("{}", prompt);
|
||||
io::stderr().flush().unwrap();
|
||||
let mut pass = String::new();
|
||||
// Try to disable echo. If that fails (e.g. piped input), just read normally.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = io::stdin().as_raw_fd();
|
||||
let mut termios = unsafe {
|
||||
let mut t = std::mem::zeroed();
|
||||
libc::tcgetattr(fd, &mut t);
|
||||
t
|
||||
};
|
||||
let old = termios;
|
||||
termios.c_lflag &= !libc::ECHO;
|
||||
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) };
|
||||
io::stdin().read_line(&mut pass).unwrap();
|
||||
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) };
|
||||
eprintln!();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
io::stdin().read_line(&mut pass).unwrap();
|
||||
}
|
||||
pass.trim().to_string()
|
||||
}
|
||||
|
||||
/// Save seed encrypted with a passphrase.
|
||||
pub fn save_seed(seed: &Seed) -> anyhow::Result<()> {
|
||||
let path = seed_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// TODO: encrypt with passphrase (Argon2 + ChaCha20-Poly1305)
|
||||
|
||||
let passphrase = prompt_passphrase("Set passphrase (empty for no encryption): ");
|
||||
|
||||
if passphrase.is_empty() {
|
||||
// Plaintext (legacy, for testing)
|
||||
fs::write(&path, &seed.0)?;
|
||||
// Set permissions to owner-only on Unix
|
||||
} else {
|
||||
let confirm = prompt_passphrase("Confirm passphrase: ");
|
||||
if passphrase != confirm {
|
||||
anyhow::bail!("Passphrases don't match");
|
||||
}
|
||||
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||
|
||||
let mut key = derive_key(passphrase.as_bytes(), &salt);
|
||||
|
||||
let cipher = ChaCha20Poly1305::new((&key).into());
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, seed.0.as_slice())
|
||||
.map_err(|_| anyhow::anyhow!("encryption failed"))?;
|
||||
|
||||
// File format: MAGIC(4) + salt(16) + nonce(12) + ciphertext(32+16=48)
|
||||
let mut file_data = Vec::with_capacity(4 + SALT_LEN + NONCE_LEN + ciphertext.len());
|
||||
file_data.extend_from_slice(MAGIC);
|
||||
file_data.extend_from_slice(&salt);
|
||||
file_data.extend_from_slice(&nonce_bytes);
|
||||
file_data.extend_from_slice(&ciphertext);
|
||||
|
||||
fs::write(&path, &file_data)?;
|
||||
key.zeroize();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load raw seed bytes (for deriving eth address etc).
|
||||
pub fn load_seed_raw() -> anyhow::Result<[u8; 32]> {
|
||||
let seed = load_seed()?;
|
||||
Ok(seed.0)
|
||||
}
|
||||
|
||||
/// Load seed, decrypting if necessary.
|
||||
pub fn load_seed() -> anyhow::Result<Seed> {
|
||||
let path = seed_path();
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|_| anyhow::anyhow!("No identity found. Run `warzone init` first."))?;
|
||||
if bytes.len() != 32 {
|
||||
anyhow::bail!("Corrupted seed file");
|
||||
|
||||
// Check if encrypted
|
||||
if bytes.len() >= 4 && &bytes[..4] == MAGIC {
|
||||
// Encrypted format
|
||||
if bytes.len() < 4 + SALT_LEN + NONCE_LEN + 48 {
|
||||
anyhow::bail!("Corrupted encrypted seed file");
|
||||
}
|
||||
|
||||
let salt = &bytes[4..4 + SALT_LEN];
|
||||
let nonce_bytes = &bytes[4 + SALT_LEN..4 + SALT_LEN + NONCE_LEN];
|
||||
let ciphertext = &bytes[4 + SALT_LEN + NONCE_LEN..];
|
||||
|
||||
let passphrase = prompt_passphrase("Passphrase: ");
|
||||
let mut key = derive_key(passphrase.as_bytes(), salt);
|
||||
|
||||
let cipher = ChaCha20Poly1305::new((&key).into());
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| anyhow::anyhow!("Wrong passphrase"))?;
|
||||
|
||||
key.zeroize();
|
||||
|
||||
if plaintext.len() != 32 {
|
||||
anyhow::bail!("Corrupted seed data");
|
||||
}
|
||||
let mut seed_bytes = [0u8; 32];
|
||||
seed_bytes.copy_from_slice(&plaintext);
|
||||
Ok(Seed::from_bytes(seed_bytes))
|
||||
} else if bytes.len() == 32 {
|
||||
// Legacy plaintext
|
||||
let mut seed_bytes = [0u8; 32];
|
||||
seed_bytes.copy_from_slice(&bytes);
|
||||
Ok(Seed::from_bytes(seed_bytes))
|
||||
} else {
|
||||
anyhow::bail!("Corrupted seed file (unknown format)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct Cli {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Generate a new identity (seed + keypair)
|
||||
/// Generate a new identity (seed + keypair + pre-keys)
|
||||
Init,
|
||||
/// Recover identity from BIP39 mnemonic
|
||||
Recover {
|
||||
@@ -25,9 +25,17 @@ enum Commands {
|
||||
},
|
||||
/// Show your fingerprint and public key
|
||||
Info,
|
||||
/// Register your key bundle with a server
|
||||
Register {
|
||||
/// Server URL
|
||||
#[arg(short, long, default_value = "http://localhost:7700")]
|
||||
server: String,
|
||||
},
|
||||
/// Show Ethereum-compatible address derived from your seed
|
||||
Eth,
|
||||
/// Send an encrypted message
|
||||
Send {
|
||||
/// Recipient fingerprint (e.g. a3f8:c912:44be:7d01)
|
||||
/// Recipient fingerprint (e.g. a3f8:c912:...) or @alias
|
||||
recipient: String,
|
||||
/// Message text
|
||||
message: String,
|
||||
@@ -43,31 +51,94 @@ enum Commands {
|
||||
},
|
||||
/// Launch interactive TUI chat
|
||||
Chat {
|
||||
/// Peer fingerprint or @alias (optional)
|
||||
peer: Option<String>,
|
||||
/// Server URL
|
||||
#[arg(short, long, default_value = "http://localhost:7700")]
|
||||
server: String,
|
||||
},
|
||||
/// Export encrypted backup of local data (sessions, history)
|
||||
Backup {
|
||||
/// Output file path
|
||||
#[arg(default_value = "warzone-backup.wzb")]
|
||||
output: String,
|
||||
},
|
||||
/// Restore from encrypted backup
|
||||
Restore {
|
||||
/// Backup file path
|
||||
input: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Init => cli::init::run()?,
|
||||
Commands::Recover { words } => cli::recover::run(&words.join(" "))?,
|
||||
Commands::Info => cli::info::run()?,
|
||||
// These don't need an existing identity
|
||||
Commands::Init => return cli::init::run(),
|
||||
Commands::Recover { words } => return cli::recover::run(&words.join(" ")),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// All other commands need the seed — unlock once here
|
||||
let seed = keystore::load_seed()?;
|
||||
// Create a copy for the poll thread (Seed doesn't impl Clone due to Zeroize)
|
||||
let poll_seed = warzone_protocol::identity::Seed::from_bytes(seed.0);
|
||||
let identity = seed.derive_identity();
|
||||
let our_fp = identity.public_identity().fingerprint.to_string();
|
||||
|
||||
match cli.command {
|
||||
Commands::Init | Commands::Recover { .. } => unreachable!(),
|
||||
Commands::Info => {
|
||||
let pub_id = identity.public_identity();
|
||||
println!("Fingerprint: {}", pub_id.fingerprint);
|
||||
println!("Signing key: {}", hex::encode(pub_id.signing.as_bytes()));
|
||||
println!("Encryption key: {}", hex::encode(pub_id.encryption.as_bytes()));
|
||||
}
|
||||
Commands::Eth => {
|
||||
let eth_id = warzone_protocol::ethereum::derive_eth_identity(&seed.0);
|
||||
let pub_id = identity.public_identity();
|
||||
println!("Warzone fingerprint: {}", pub_id.fingerprint);
|
||||
println!("Ethereum address: {}", eth_id.address.to_checksum());
|
||||
println!("Same seed, dual identity.");
|
||||
}
|
||||
Commands::Register { server } => {
|
||||
cli::init::register_with_server_identity(&server, &identity).await?;
|
||||
}
|
||||
Commands::Send {
|
||||
recipient,
|
||||
message,
|
||||
server,
|
||||
} => {
|
||||
println!("TODO: send '{}' to {} via {}", message, recipient, server);
|
||||
let _ = cli::init::register_with_server_identity(&server, &identity).await;
|
||||
cli::send::run(&recipient, &message, &server, &identity).await?;
|
||||
}
|
||||
Commands::Recv { server } => {
|
||||
println!("TODO: poll messages from {}", server);
|
||||
cli::recv::run(&server, &identity).await?;
|
||||
}
|
||||
Commands::Chat { server } => {
|
||||
println!("TODO: launch TUI connected to {}", server);
|
||||
Commands::Chat { peer, server } => {
|
||||
let _ = cli::init::register_with_server_identity(&server, &identity).await;
|
||||
let db = storage::LocalDb::open()?;
|
||||
tui::run_tui(our_fp, peer, server, identity, poll_seed, db).await?;
|
||||
}
|
||||
Commands::Backup { output } => {
|
||||
// Collect all sled data as JSON
|
||||
let db = storage::LocalDb::open()?;
|
||||
let backup_data = db.export_all()?;
|
||||
let json = serde_json::to_vec(&backup_data)?;
|
||||
let encrypted = warzone_protocol::history::encrypt_history(&seed.0, &json);
|
||||
std::fs::write(&output, &encrypted)?;
|
||||
println!("Backup saved to {} ({} bytes encrypted)", output, encrypted.len());
|
||||
}
|
||||
Commands::Restore { input } => {
|
||||
let encrypted = std::fs::read(&input)?;
|
||||
let json = warzone_protocol::history::decrypt_history(&seed.0, &encrypted)
|
||||
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong seed?"))?;
|
||||
let backup_data: serde_json::Value = serde_json::from_slice(&json)?;
|
||||
let db = storage::LocalDb::open()?;
|
||||
let count = db.import_all(&backup_data)?;
|
||||
println!("Restored {} entries from {}", count, input);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,172 @@
|
||||
// HTTP client for talking to warzone-server.
|
||||
// TODO: implement in Phase 1 step 9.
|
||||
//! HTTP client for talking to warzone-server.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warzone_protocol::prekey::PreKeyBundle;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerClient {
|
||||
pub base_url: String,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RegisterRequest {
|
||||
fingerprint: String,
|
||||
bundle: Vec<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
eth_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SendRequest {
|
||||
to: String,
|
||||
from: Option<String>,
|
||||
message: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct BundleResponse {
|
||||
fingerprint: String,
|
||||
bundle: String, // base64
|
||||
}
|
||||
|
||||
impl ServerClient {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
ServerClient {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register our pre-key bundle with the server.
|
||||
pub async fn register_bundle(
|
||||
&self,
|
||||
fingerprint: &str,
|
||||
bundle: &PreKeyBundle,
|
||||
eth_address: Option<String>,
|
||||
) -> Result<()> {
|
||||
let encoded =
|
||||
bincode::serialize(bundle).context("failed to serialize bundle")?;
|
||||
self.client
|
||||
.post(format!("{}/v1/keys/register", self.base_url))
|
||||
.json(&RegisterRequest {
|
||||
fingerprint: fingerprint.to_string(),
|
||||
bundle: encoded,
|
||||
eth_address,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.context("failed to register bundle")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a user's pre-key bundle from the server.
|
||||
pub async fn fetch_bundle(&self, fingerprint: &str) -> Result<PreKeyBundle> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let response = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/v1/keys/{}",
|
||||
self.base_url, fp_clean
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to fetch bundle")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"server returned {} — user {} may not be registered",
|
||||
response.status(),
|
||||
fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
let resp: BundleResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse bundle response")?;
|
||||
|
||||
let bytes = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&resp.bundle,
|
||||
)
|
||||
.context("failed to decode base64 bundle")?;
|
||||
|
||||
bincode::deserialize(&bytes).context("failed to deserialize bundle")
|
||||
}
|
||||
|
||||
/// Send an encrypted message to the server for delivery.
|
||||
pub async fn send_message(&self, to: &str, from: Option<&str>, message: &[u8]) -> Result<()> {
|
||||
let to_clean: String = to.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
self.client
|
||||
.post(format!("{}/v1/messages/send", self.base_url))
|
||||
.json(&SendRequest {
|
||||
to: to_clean,
|
||||
from: from.map(|f| f.chars().filter(|c| c.is_ascii_hexdigit()).collect()),
|
||||
message: message.to_vec(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send message")?;
|
||||
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();
|
||||
let resp: Vec<String> = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/v1/messages/poll/{}",
|
||||
self.base_url, fp_clean
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to poll messages")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse poll response")?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for b64 in resp {
|
||||
if let Ok(bytes) = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&b64,
|
||||
) {
|
||||
messages.push(bytes);
|
||||
}
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,412 @@
|
||||
// Local sled database: sessions, contacts, message history.
|
||||
// TODO: implement in Phase 1 step 9.
|
||||
//! Local sled database: sessions, pre-keys, message history.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
use x25519_dalek::StaticSecret;
|
||||
|
||||
pub struct LocalDb {
|
||||
sessions: sled::Tree,
|
||||
pre_keys: sled::Tree,
|
||||
contacts: sled::Tree,
|
||||
history: sled::Tree,
|
||||
sender_keys: sled::Tree,
|
||||
_db: sled::Db,
|
||||
}
|
||||
|
||||
impl LocalDb {
|
||||
pub fn open() -> Result<Self> {
|
||||
let path = crate::keystore::data_dir().join("db");
|
||||
let db = match sled::open(&path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("WouldBlock") || err_str.contains("lock") {
|
||||
eprintln!("Error: Database is locked by another warzone process.");
|
||||
eprintln!(" DB path: {}", path.display());
|
||||
eprintln!();
|
||||
eprintln!(" Check for running processes:");
|
||||
eprintln!(" ps aux | grep warzone-client");
|
||||
eprintln!();
|
||||
eprintln!(" To force unlock (if no other process is running):");
|
||||
eprintln!(" rm -rf {}", path.display());
|
||||
eprintln!(" (This deletes sessions — you'll need to re-establish them)");
|
||||
anyhow::bail!("database locked by another process");
|
||||
}
|
||||
return Err(e).context("failed to open local database");
|
||||
}
|
||||
};
|
||||
let sessions = db.open_tree("sessions")?;
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a ratchet session for a peer.
|
||||
pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> {
|
||||
let key = peer.to_hex();
|
||||
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 = RatchetState::deserialize_versioned(&data)
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the signed pre-key secret (for X3DH respond).
|
||||
pub fn save_signed_pre_key(&self, id: u32, secret: &StaticSecret) -> Result<()> {
|
||||
let key = format!("spk:{}", id);
|
||||
self.pre_keys
|
||||
.insert(key.as_bytes(), secret.to_bytes().as_slice())?;
|
||||
self.pre_keys.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the signed pre-key secret.
|
||||
pub fn load_signed_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
|
||||
let key = format!("spk:{}", id);
|
||||
match self.pre_keys.get(key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&data);
|
||||
Ok(Some(StaticSecret::from(bytes)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a one-time pre-key secret.
|
||||
pub fn save_one_time_pre_key(&self, id: u32, secret: &StaticSecret) -> Result<()> {
|
||||
let key = format!("otpk:{}", id);
|
||||
self.pre_keys
|
||||
.insert(key.as_bytes(), secret.to_bytes().as_slice())?;
|
||||
self.pre_keys.flush()?;
|
||||
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);
|
||||
match self.pre_keys.remove(key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&data);
|
||||
self.pre_keys.flush()?;
|
||||
Ok(Some(StaticSecret::from(bytes)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
pub fn touch_contact(&self, fingerprint: &str, alias: Option<&str>) -> Result<()> {
|
||||
let fp = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
let mut record = match self.contacts.get(fp.as_bytes())? {
|
||||
Some(data) => serde_json::from_slice::<serde_json::Value>(&data).unwrap_or_default(),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
let obj = record.as_object_mut().unwrap();
|
||||
obj.insert("fingerprint".into(), serde_json::json!(fp));
|
||||
obj.insert("last_seen".into(), serde_json::json!(now));
|
||||
if let Some(a) = alias {
|
||||
obj.insert("alias".into(), serde_json::json!(a));
|
||||
}
|
||||
if !obj.contains_key("first_seen") {
|
||||
obj.insert("first_seen".into(), serde_json::json!(now));
|
||||
}
|
||||
let count = obj.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
obj.insert("message_count".into(), serde_json::json!(count + 1));
|
||||
|
||||
self.contacts.insert(fp.as_bytes(), serde_json::to_vec(&record)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all contacts sorted by last_seen (most recent first).
|
||||
pub fn list_contacts(&self) -> Result<Vec<serde_json::Value>> {
|
||||
let mut contacts: Vec<serde_json::Value> = self.contacts.iter()
|
||||
.filter_map(|item| {
|
||||
item.ok().and_then(|(_, data)| serde_json::from_slice(&data).ok())
|
||||
})
|
||||
.collect();
|
||||
contacts.sort_by(|a, b| {
|
||||
let ta = a.get("last_seen").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("last_seen").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
// ── Message History ──
|
||||
|
||||
/// Store a message in local history.
|
||||
pub fn store_message(&self, peer_fp: &str, sender: &str, text: &str, is_self: bool) -> Result<()> {
|
||||
let fp = peer_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let msg = serde_json::json!({
|
||||
"id": id,
|
||||
"peer": fp,
|
||||
"sender": sender,
|
||||
"text": text,
|
||||
"is_self": is_self,
|
||||
"timestamp": now,
|
||||
});
|
||||
|
||||
// Key: hist:<peer_fp>:<timestamp>:<uuid> for ordered scan
|
||||
let key = format!("hist:{}:{}:{}", fp, now, id);
|
||||
self.history.insert(key.as_bytes(), serde_json::to_vec(&msg)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get message history with a peer (most recent N messages).
|
||||
pub fn get_history(&self, peer_fp: &str, limit: usize) -> Result<Vec<serde_json::Value>> {
|
||||
let fp = peer_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
let prefix = format!("hist:{}:", fp);
|
||||
|
||||
let mut messages: Vec<serde_json::Value> = self.history
|
||||
.scan_prefix(prefix.as_bytes())
|
||||
.filter_map(|item| {
|
||||
item.ok().and_then(|(_, data)| serde_json::from_slice(&data).ok())
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Take last N
|
||||
if messages.len() > limit {
|
||||
messages = messages.split_off(messages.len() - limit);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Export all data as JSON (for encrypted backup).
|
||||
pub fn export_all(&self) -> Result<serde_json::Value> {
|
||||
let mut sessions = serde_json::Map::new();
|
||||
for item in self.sessions.iter() {
|
||||
if let Ok((k, v)) = item {
|
||||
let key = String::from_utf8_lossy(&k).to_string();
|
||||
sessions.insert(key, serde_json::json!(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD, &v
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut pre_keys = serde_json::Map::new();
|
||||
for item in self.pre_keys.iter() {
|
||||
if let Ok((k, v)) = item {
|
||||
let key = String::from_utf8_lossy(&k).to_string();
|
||||
pre_keys.insert(key, serde_json::json!(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD, &v
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"version": 1,
|
||||
"sessions": sessions,
|
||||
"pre_keys": pre_keys,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
if let Some(sessions) = data.get("sessions").and_then(|v| v.as_object()) {
|
||||
for (key, val) in sessions {
|
||||
if let Some(b64) = val.as_str() {
|
||||
if let Ok(bytes) = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD, b64
|
||||
) {
|
||||
// Only import if not already present
|
||||
if self.sessions.get(key.as_bytes())?.is_none() {
|
||||
self.sessions.insert(key.as_bytes(), bytes)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pre_keys) = data.get("pre_keys").and_then(|v| v.as_object()) {
|
||||
for (key, val) in pre_keys {
|
||||
if let Some(b64) = val.as_str() {
|
||||
if let Ok(bytes) = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD, b64
|
||||
) {
|
||||
if self.pre_keys.get(key.as_bytes())?.is_none() {
|
||||
self.pre_keys.insert(key.as_bytes(), bytes)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.sessions.flush()?;
|
||||
self.pre_keys.flush()?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// TUI App struct and event loop.
|
||||
// TODO: implement in Phase 1 step 10.
|
||||
1221
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
1221
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
File diff suppressed because it is too large
Load Diff
506
warzone/crates/warzone-client/src/tui/draw.rs
Normal file
506
warzone/crates/warzone-client/src/tui/draw.rs
Normal 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.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal file
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
454
warzone/crates/warzone-client/src/tui/input.rs
Normal file
454
warzone/crates/warzone-client/src/tui/input.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,195 @@
|
||||
// TUI scaffold — ratatui app.
|
||||
// TODO: implement in Phase 1 step 10.
|
||||
pub mod app;
|
||||
mod types;
|
||||
mod draw;
|
||||
mod commands;
|
||||
mod file_transfer;
|
||||
mod input;
|
||||
mod network;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
707
warzone/crates/warzone-client/src/tui/network.rs
Normal file
707
warzone/crates/warzone-client/src/tui/network.rs
Normal 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.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, ð_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, ð_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, ð_cache, &last_dm_peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
267
warzone/crates/warzone-client/src/tui/types.rs
Normal file
267
warzone/crates/warzone-client/src/tui/types.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,48 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
version = "0.0.44"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
||||
rust-version = "1.75"
|
||||
|
||||
# This crate is designed to be importable standalone — no workspace inheritance.
|
||||
# WarzonePhone and other projects can depend on it directly via path or git.
|
||||
|
||||
[dependencies]
|
||||
ed25519-dalek.workspace = true
|
||||
x25519-dalek.workspace = true
|
||||
curve25519-dalek.workspace = true
|
||||
chacha20poly1305.workspace = true
|
||||
hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
rand.workspace = true
|
||||
bip39.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
bincode.workspace = true
|
||||
thiserror.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
chrono.workspace = true
|
||||
# Crypto
|
||||
ed25519-dalek = { version = "2", features = ["serde", "rand_core"] }
|
||||
x25519-dalek = { version = "2", features = ["serde", "static_secrets"] }
|
||||
curve25519-dalek = "4"
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
|
||||
# Ethereum compatibility
|
||||
k256 = { version = "0.13", features = ["ecdsa", "serde"] }
|
||||
tiny-keccak = { version = "2", features = ["keccak"] }
|
||||
|
||||
# BIP39
|
||||
bip39 = "2"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bincode = "1"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Encoding
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# Memory safety
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
177
warzone/crates/warzone-protocol/src/ethereum.rs
Normal file
177
warzone/crates/warzone-protocol/src/ethereum.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
//! Ethereum-compatible identity: secp256k1 keypair + Ethereum address.
|
||||
//!
|
||||
//! From the same BIP39 seed, derive:
|
||||
//! - secp256k1 keypair (Ethereum-compatible signing)
|
||||
//! - Ethereum address = Keccak-256(uncompressed_pubkey[1..])[-20:]
|
||||
//! - The Ethereum address can serve as the user's public identity/fingerprint
|
||||
//!
|
||||
//! This enables:
|
||||
//! - MetaMask/Rabby wallet connect (sign challenge)
|
||||
//! - ENS resolution (@vitalik.eth → 0xd8dA... → Warzone identity)
|
||||
//! - Hardware wallet support (Ledger/Trezor already support secp256k1)
|
||||
|
||||
use k256::ecdsa::{SigningKey, VerifyingKey, Signature, signature::Signer, signature::Verifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tiny_keccak::{Hasher, Keccak};
|
||||
|
||||
use crate::crypto::hkdf_derive;
|
||||
|
||||
/// An Ethereum-compatible identity derived from a Warzone seed.
|
||||
#[derive(Clone)]
|
||||
pub struct EthIdentity {
|
||||
pub signing_key: SigningKey,
|
||||
pub verifying_key: VerifyingKey,
|
||||
pub address: EthAddress,
|
||||
}
|
||||
|
||||
/// An Ethereum address (20 bytes).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct EthAddress(pub [u8; 20]);
|
||||
|
||||
impl std::fmt::Display for EthAddress {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "0x{}", hex::encode(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EthAddress {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "EthAddress({})", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl EthAddress {
|
||||
/// Parse from hex string (with or without 0x prefix).
|
||||
pub fn from_hex(s: &str) -> Result<Self, crate::errors::ProtocolError> {
|
||||
let clean = s.trim_start_matches("0x").trim_start_matches("0X");
|
||||
let bytes = hex::decode(clean)
|
||||
.map_err(|_| crate::errors::ProtocolError::InvalidFingerprint)?;
|
||||
if bytes.len() != 20 {
|
||||
return Err(crate::errors::ProtocolError::InvalidFingerprint);
|
||||
}
|
||||
let mut addr = [0u8; 20];
|
||||
addr.copy_from_slice(&bytes);
|
||||
Ok(EthAddress(addr))
|
||||
}
|
||||
|
||||
/// EIP-55 checksum address.
|
||||
pub fn to_checksum(&self) -> String {
|
||||
let hex_addr = hex::encode(self.0);
|
||||
let mut hasher = Keccak::v256();
|
||||
hasher.update(hex_addr.as_bytes());
|
||||
let mut hash = [0u8; 32];
|
||||
hasher.finalize(&mut hash);
|
||||
|
||||
let mut result = String::from("0x");
|
||||
for (i, c) in hex_addr.chars().enumerate() {
|
||||
let nibble = (hash[i / 2] >> (if i % 2 == 0 { 4 } else { 0 })) & 0x0f;
|
||||
if nibble >= 8 {
|
||||
result.push(c.to_uppercase().next().unwrap());
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive an Ethereum identity from a Warzone seed.
|
||||
/// Uses HKDF with info="warzone-secp256k1" for domain separation.
|
||||
pub fn derive_eth_identity(seed: &[u8; 32]) -> EthIdentity {
|
||||
let derived = hkdf_derive(seed, b"", b"warzone-secp256k1", 32);
|
||||
let mut key_bytes = [0u8; 32];
|
||||
key_bytes.copy_from_slice(&derived);
|
||||
|
||||
let signing_key = SigningKey::from_bytes((&key_bytes).into())
|
||||
.expect("valid secp256k1 key");
|
||||
let verifying_key = *signing_key.verifying_key();
|
||||
|
||||
// Ethereum address: Keccak-256 of uncompressed public key (without 0x04 prefix)
|
||||
let pubkey_uncompressed = verifying_key.to_encoded_point(false);
|
||||
let pubkey_bytes = &pubkey_uncompressed.as_bytes()[1..]; // skip 0x04 prefix
|
||||
|
||||
let mut hasher = Keccak::v256();
|
||||
hasher.update(pubkey_bytes);
|
||||
let mut hash = [0u8; 32];
|
||||
hasher.finalize(&mut hash);
|
||||
|
||||
let mut address = [0u8; 20];
|
||||
address.copy_from_slice(&hash[12..]); // last 20 bytes
|
||||
|
||||
EthIdentity {
|
||||
signing_key,
|
||||
verifying_key,
|
||||
address: EthAddress(address),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a message with the Ethereum identity (produces a secp256k1 ECDSA signature).
|
||||
pub fn eth_sign(identity: &EthIdentity, message: &[u8]) -> Vec<u8> {
|
||||
let signature: Signature = identity.signing_key.sign(message);
|
||||
signature.to_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Verify a secp256k1 signature.
|
||||
pub fn eth_verify(verifying_key: &VerifyingKey, message: &[u8], signature: &[u8]) -> bool {
|
||||
if let Ok(sig) = Signature::from_slice(signature) {
|
||||
verifying_key.verify(message, &sig).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the Ethereum address from a Warzone fingerprint.
|
||||
/// This allows mapping: Warzone fingerprint ↔ Ethereum address (from same seed).
|
||||
pub fn fingerprint_to_eth_address(seed: &[u8; 32]) -> EthAddress {
|
||||
derive_eth_identity(seed).address
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derive_deterministic() {
|
||||
let seed = [42u8; 32];
|
||||
let id1 = derive_eth_identity(&seed);
|
||||
let id2 = derive_eth_identity(&seed);
|
||||
assert_eq!(id1.address.0, id2.address.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_format() {
|
||||
let seed = [42u8; 32];
|
||||
let id = derive_eth_identity(&seed);
|
||||
let addr = id.address.to_string();
|
||||
assert!(addr.starts_with("0x"));
|
||||
assert_eq!(addr.len(), 42); // 0x + 40 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_address() {
|
||||
let seed = [42u8; 32];
|
||||
let id = derive_eth_identity(&seed);
|
||||
let checksum = id.address.to_checksum();
|
||||
assert!(checksum.starts_with("0x"));
|
||||
assert_eq!(checksum.len(), 42);
|
||||
// Should have mixed case (EIP-55)
|
||||
assert!(checksum[2..].chars().any(|c| c.is_uppercase()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_verify() {
|
||||
let seed = [42u8; 32];
|
||||
let id = derive_eth_identity(&seed);
|
||||
let msg = b"hello ethereum";
|
||||
let sig = eth_sign(&id, msg);
|
||||
assert!(eth_verify(&id.verifying_key, msg, &sig));
|
||||
assert!(!eth_verify(&id.verifying_key, b"wrong", &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_different_addresses() {
|
||||
let id1 = derive_eth_identity(&[1u8; 32]);
|
||||
let id2 = derive_eth_identity(&[2u8; 32]);
|
||||
assert_ne!(id1.address.0, id2.address.0);
|
||||
}
|
||||
}
|
||||
113
warzone/crates/warzone-protocol/src/friends.rs
Normal file
113
warzone/crates/warzone-protocol/src/friends.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
58
warzone/crates/warzone-protocol/src/history.rs
Normal file
58
warzone/crates/warzone-protocol/src/history.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Encrypted message history: backup and restore.
|
||||
//!
|
||||
//! History key derived from seed via HKDF (info="warzone-history").
|
||||
//! Format: MAGIC(4) + nonce(12) + ciphertext (ChaCha20-Poly1305).
|
||||
|
||||
use crate::crypto::{aead_decrypt, aead_encrypt, hkdf_derive};
|
||||
use crate::errors::ProtocolError;
|
||||
|
||||
const HISTORY_MAGIC: &[u8; 4] = b"WZH1";
|
||||
|
||||
/// Derive history encryption key from seed.
|
||||
pub fn derive_history_key(seed: &[u8; 32]) -> [u8; 32] {
|
||||
let derived = hkdf_derive(seed, b"", b"warzone-history", 32);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&derived);
|
||||
key
|
||||
}
|
||||
|
||||
/// Encrypt a history blob (JSON messages serialized to bytes).
|
||||
pub fn encrypt_history(seed: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
|
||||
let key = derive_history_key(seed);
|
||||
let encrypted = aead_encrypt(&key, plaintext, HISTORY_MAGIC);
|
||||
let mut result = Vec::with_capacity(4 + encrypted.len());
|
||||
result.extend_from_slice(HISTORY_MAGIC);
|
||||
result.extend_from_slice(&encrypted);
|
||||
result
|
||||
}
|
||||
|
||||
/// Decrypt a history blob.
|
||||
pub fn decrypt_history(seed: &[u8; 32], data: &[u8]) -> Result<Vec<u8>, ProtocolError> {
|
||||
if data.len() < 4 || &data[..4] != HISTORY_MAGIC {
|
||||
return Err(ProtocolError::DecryptionFailed);
|
||||
}
|
||||
let key = derive_history_key(seed);
|
||||
aead_decrypt(&key, &data[4..], HISTORY_MAGIC)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let seed = [42u8; 32];
|
||||
let messages = b"[{\"from\":\"alice\",\"text\":\"hello\"}]";
|
||||
let encrypted = encrypt_history(&seed, messages);
|
||||
let decrypted = decrypt_history(&seed, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_seed_fails() {
|
||||
let seed = [42u8; 32];
|
||||
let wrong = [99u8; 32];
|
||||
let encrypted = encrypt_history(&seed, b"secret");
|
||||
assert!(decrypt_history(&wrong, &encrypted).is_err());
|
||||
}
|
||||
}
|
||||
@@ -175,8 +175,8 @@ mod tests {
|
||||
let id = seed.derive_identity();
|
||||
let pub_id = id.public_identity();
|
||||
let fp_str = pub_id.fingerprint.to_string();
|
||||
// Format: xxxx:xxxx:xxxx:xxxx
|
||||
assert_eq!(fp_str.len(), 19);
|
||||
assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 3);
|
||||
// Format: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
|
||||
assert_eq!(fp_str.len(), 39);
|
||||
assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,7 @@ pub mod ratchet;
|
||||
pub mod message;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
pub mod history;
|
||||
pub mod sender_keys;
|
||||
pub mod ethereum;
|
||||
pub mod friends;
|
||||
|
||||
@@ -33,3 +33,203 @@ pub enum MessageContent {
|
||||
File { filename: String, data: Vec<u8> },
|
||||
Receipt { message_id: MessageId },
|
||||
}
|
||||
|
||||
/// Receipt type: delivered (received + decrypted) or read (user viewed).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ReceiptType {
|
||||
Delivered,
|
||||
Read,
|
||||
}
|
||||
|
||||
/// Wire message format for transport between clients.
|
||||
/// Used by both CLI and WASM — MUST be identical for interop.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum WireMessage {
|
||||
/// First message to a peer: X3DH key exchange + first ratchet message.
|
||||
KeyExchange {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
sender_identity_encryption_key: [u8; 32],
|
||||
ephemeral_public: [u8; 32],
|
||||
used_one_time_pre_key_id: Option<u32>,
|
||||
ratchet_message: crate::ratchet::RatchetMessage,
|
||||
},
|
||||
/// Subsequent messages: ratchet-encrypted.
|
||||
Message {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
ratchet_message: crate::ratchet::RatchetMessage,
|
||||
},
|
||||
/// Delivery / read receipt (plaintext, not encrypted).
|
||||
Receipt {
|
||||
sender_fingerprint: String,
|
||||
message_id: String,
|
||||
receipt_type: ReceiptType,
|
||||
},
|
||||
/// File transfer header: announces an incoming chunked file.
|
||||
FileHeader {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
filename: String,
|
||||
file_size: u64,
|
||||
total_chunks: u32,
|
||||
sha256: String,
|
||||
},
|
||||
/// A single chunk of a file transfer (data is ratchet-encrypted).
|
||||
FileChunk {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
filename: String,
|
||||
chunk_index: u32,
|
||||
total_chunks: u32,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Group message encrypted with sender key (O(1) instead of O(N)).
|
||||
GroupSenderKey {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
group_name: String,
|
||||
generation: u32,
|
||||
counter: u32,
|
||||
ciphertext: Vec<u8>,
|
||||
},
|
||||
/// Sender key distribution: share your sender key with a group member.
|
||||
/// This is sent via 1:1 encrypted channel (wrapped in KeyExchange/Message).
|
||||
SenderKeyDistribution {
|
||||
sender_fingerprint: String,
|
||||
group_name: String,
|
||||
chain_key: [u8; 32],
|
||||
generation: u32,
|
||||
},
|
||||
/// Call signaling: SDP offers/answers, ICE candidates, call control.
|
||||
/// Routed through featherChat's E2E encrypted channel for WarzonePhone integration.
|
||||
CallSignal {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
signal_type: CallSignalType,
|
||||
/// SDP offer/answer body, ICE candidate, or empty for hangup/reject.
|
||||
payload: String,
|
||||
/// Target peer (for 1:1) or group/room name (for group calls).
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Call signaling types for WarzonePhone integration.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum CallSignalType {
|
||||
/// Initiate a call (contains SDP offer or WZP connection params).
|
||||
Offer,
|
||||
/// Accept a call (contains SDP answer or WZP connection params).
|
||||
Answer,
|
||||
/// ICE candidate for NAT traversal.
|
||||
IceCandidate,
|
||||
/// Hang up / end call.
|
||||
Hangup,
|
||||
/// Reject incoming call.
|
||||
Reject,
|
||||
/// Call is ringing on the other side.
|
||||
Ringing,
|
||||
/// 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
210
warzone/crates/warzone-protocol/src/sender_keys.rs
Normal file
210
warzone/crates/warzone-protocol/src/sender_keys.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! Sender Keys for efficient group encryption.
|
||||
//!
|
||||
//! Instead of encrypting per-member (O(N)), each member generates a
|
||||
//! symmetric "sender key" and distributes it to all group members via
|
||||
//! 1:1 encrypted channels. Group messages are encrypted ONCE with the
|
||||
//! sender's key, and the same ciphertext is delivered to all members.
|
||||
//!
|
||||
//! Key rotation: on member join/leave, all members rotate their sender keys.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::crypto::{aead_decrypt, aead_encrypt, hkdf_derive};
|
||||
use crate::errors::ProtocolError;
|
||||
|
||||
/// A sender key: symmetric key + chain for forward ratcheting.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SenderKey {
|
||||
/// Who owns this key.
|
||||
pub owner_fingerprint: String,
|
||||
/// Group this key belongs to.
|
||||
pub group_name: String,
|
||||
/// Current chain key (ratchets forward on each message).
|
||||
pub chain_key: [u8; 32],
|
||||
/// Message counter.
|
||||
pub counter: u32,
|
||||
/// Generation (incremented on rotation).
|
||||
pub generation: u32,
|
||||
}
|
||||
|
||||
impl SenderKey {
|
||||
/// Generate a new sender key for a group.
|
||||
pub fn generate(owner_fingerprint: &str, group_name: &str) -> Self {
|
||||
let mut chain_key = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut chain_key);
|
||||
SenderKey {
|
||||
owner_fingerprint: owner_fingerprint.to_string(),
|
||||
group_name: group_name.to_string(),
|
||||
chain_key,
|
||||
counter: 0,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate: new random chain key, increment generation.
|
||||
pub fn rotate(&mut self) {
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut self.chain_key);
|
||||
self.counter = 0;
|
||||
self.generation += 1;
|
||||
}
|
||||
|
||||
/// Derive a message key from the current chain key, then ratchet forward.
|
||||
fn derive_message_key(&mut self) -> [u8; 32] {
|
||||
let info = format!("wz-sk-msg-{}-{}", self.generation, self.counter);
|
||||
let mk_bytes = hkdf_derive(&self.chain_key, b"", info.as_bytes(), 32);
|
||||
let mut message_key = [0u8; 32];
|
||||
message_key.copy_from_slice(&mk_bytes);
|
||||
|
||||
// Ratchet chain key forward
|
||||
let ck_bytes = hkdf_derive(&self.chain_key, b"", b"wz-sk-chain", 32);
|
||||
self.chain_key.copy_from_slice(&ck_bytes);
|
||||
self.counter += 1;
|
||||
|
||||
message_key
|
||||
}
|
||||
|
||||
/// Encrypt a message with this sender key.
|
||||
pub fn encrypt(&mut self, plaintext: &[u8]) -> SenderKeyMessage {
|
||||
let message_key = self.derive_message_key();
|
||||
let aad = format!("{}:{}:{}", self.group_name, self.generation, self.counter - 1);
|
||||
let ciphertext = aead_encrypt(&message_key, plaintext, aad.as_bytes());
|
||||
|
||||
SenderKeyMessage {
|
||||
sender_fingerprint: self.owner_fingerprint.clone(),
|
||||
group_name: self.group_name.clone(),
|
||||
generation: self.generation,
|
||||
counter: self.counter - 1,
|
||||
ciphertext,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a message from another member using their sender key.
|
||||
/// `self` is the RECEIVER's copy of the SENDER's key.
|
||||
pub fn decrypt(&mut self, msg: &SenderKeyMessage) -> Result<Vec<u8>, ProtocolError> {
|
||||
// Fast-forward chain if needed (handle skipped messages)
|
||||
if msg.generation != self.generation {
|
||||
return Err(ProtocolError::RatchetError(format!(
|
||||
"generation mismatch: expected {}, got {}",
|
||||
self.generation, msg.generation
|
||||
)));
|
||||
}
|
||||
|
||||
// We need to advance to the right counter
|
||||
while self.counter < msg.counter {
|
||||
// Skip this message key (lost message)
|
||||
let _ = self.derive_message_key();
|
||||
}
|
||||
|
||||
if self.counter != msg.counter {
|
||||
return Err(ProtocolError::RatchetError("counter mismatch".into()));
|
||||
}
|
||||
|
||||
let message_key = self.derive_message_key();
|
||||
let aad = format!("{}:{}:{}", msg.group_name, msg.generation, msg.counter);
|
||||
aead_decrypt(&message_key, &msg.ciphertext, aad.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// An encrypted group message using sender keys.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SenderKeyMessage {
|
||||
pub sender_fingerprint: String,
|
||||
pub group_name: String,
|
||||
pub generation: u32,
|
||||
pub counter: u32,
|
||||
pub ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Distribution message: sent via 1:1 encrypted channel to share a sender key.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SenderKeyDistribution {
|
||||
pub sender_fingerprint: String,
|
||||
pub group_name: String,
|
||||
pub chain_key: [u8; 32],
|
||||
pub generation: u32,
|
||||
}
|
||||
|
||||
impl From<&SenderKey> for SenderKeyDistribution {
|
||||
fn from(sk: &SenderKey) -> Self {
|
||||
SenderKeyDistribution {
|
||||
sender_fingerprint: sk.owner_fingerprint.clone(),
|
||||
group_name: sk.group_name.clone(),
|
||||
chain_key: sk.chain_key,
|
||||
generation: sk.generation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SenderKeyDistribution {
|
||||
/// Convert distribution into a receiver's copy of the sender key.
|
||||
pub fn into_sender_key(self) -> SenderKey {
|
||||
SenderKey {
|
||||
owner_fingerprint: self.sender_fingerprint,
|
||||
group_name: self.group_name,
|
||||
chain_key: self.chain_key,
|
||||
counter: 0,
|
||||
generation: self.generation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_encrypt_decrypt() {
|
||||
let mut alice_key = SenderKey::generate("alice", "ops");
|
||||
// Bob gets a copy of Alice's key (via distribution)
|
||||
let dist = SenderKeyDistribution::from(&alice_key);
|
||||
let mut bob_copy = dist.into_sender_key();
|
||||
|
||||
let msg = alice_key.encrypt(b"hello group");
|
||||
let plain = bob_copy.decrypt(&msg).unwrap();
|
||||
assert_eq!(plain, b"hello group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_messages() {
|
||||
let mut alice_key = SenderKey::generate("alice", "ops");
|
||||
let dist = SenderKeyDistribution::from(&alice_key);
|
||||
let mut bob_copy = dist.into_sender_key();
|
||||
|
||||
for i in 0..10 {
|
||||
let msg = alice_key.encrypt(format!("msg {}", i).as_bytes());
|
||||
let plain = bob_copy.decrypt(&msg).unwrap();
|
||||
assert_eq!(plain, format!("msg {}", i).as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation() {
|
||||
let mut alice_key = SenderKey::generate("alice", "ops");
|
||||
let dist1 = SenderKeyDistribution::from(&alice_key);
|
||||
let mut bob_copy = dist1.into_sender_key();
|
||||
|
||||
let msg1 = alice_key.encrypt(b"before rotation");
|
||||
let _ = bob_copy.decrypt(&msg1).unwrap();
|
||||
|
||||
// Rotate
|
||||
alice_key.rotate();
|
||||
let dist2 = SenderKeyDistribution::from(&alice_key);
|
||||
let mut bob_copy2 = dist2.into_sender_key();
|
||||
|
||||
let msg2 = alice_key.encrypt(b"after rotation");
|
||||
let plain = bob_copy2.decrypt(&msg2).unwrap();
|
||||
assert_eq!(plain, b"after rotation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_key_cant_decrypt_new() {
|
||||
let mut alice_key = SenderKey::generate("alice", "ops");
|
||||
let dist = SenderKeyDistribution::from(&alice_key);
|
||||
let mut bob_old = dist.into_sender_key();
|
||||
|
||||
alice_key.rotate();
|
||||
|
||||
let msg = alice_key.encrypt(b"new generation");
|
||||
assert!(bob_old.decrypt(&msg).is_err());
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,15 @@ impl fmt::Display for Fingerprint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{:04x}:{:04x}:{:04x}:{:04x}",
|
||||
"{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}",
|
||||
u16::from_be_bytes([self.0[0], self.0[1]]),
|
||||
u16::from_be_bytes([self.0[2], self.0[3]]),
|
||||
u16::from_be_bytes([self.0[4], self.0[5]]),
|
||||
u16::from_be_bytes([self.0[6], self.0[7]]),
|
||||
u16::from_be_bytes([self.0[8], self.0[9]]),
|
||||
u16::from_be_bytes([self.0[10], self.0[11]]),
|
||||
u16::from_be_bytes([self.0[12], self.0[13]]),
|
||||
u16::from_be_bytes([self.0[14], self.0[15]]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,14 @@ uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
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"] }
|
||||
|
||||
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
282
warzone/crates/warzone-server/src/botfather.rs
Normal file
282
warzone/crates/warzone-server/src/botfather.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,2 @@
|
||||
pub struct ServerConfig {
|
||||
pub bind_addr: String,
|
||||
pub data_dir: String,
|
||||
}
|
||||
// Server configuration — currently handled via CLI args in main.rs.
|
||||
// This module will be used when file-based configuration is added.
|
||||
|
||||
@@ -3,7 +3,13 @@ use anyhow::Result;
|
||||
pub struct Database {
|
||||
pub keys: sled::Tree,
|
||||
pub messages: sled::Tree,
|
||||
pub otpks: sled::Tree,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -12,11 +18,23 @@ impl Database {
|
||||
let db = sled::open(data_dir)?;
|
||||
let keys = db.open_tree("keys")?;
|
||||
let messages = db.open_tree("messages")?;
|
||||
let otpks = db.open_tree("otpks")?;
|
||||
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,
|
||||
otpks,
|
||||
groups,
|
||||
aliases,
|
||||
tokens,
|
||||
calls,
|
||||
missed_calls,
|
||||
friends,
|
||||
eth_addresses,
|
||||
_db: db,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// Wraps anyhow::Error into an axum-compatible error response.
|
||||
pub struct AppError(pub anyhow::Error);
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
tracing::error!("{:#}", self.0);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
@@ -14,3 +16,6 @@ impl<E: Into<anyhow::Error>> From<E> for AppError {
|
||||
AppError(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience type for route handlers.
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
340
warzone/crates/warzone-server/src/federation.rs
Normal file
340
warzone/crates/warzone-server/src/federation.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,20 +19,231 @@ 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]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,tower_http=debug".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
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);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
|
||||
|
||||
436
warzone/crates/warzone-server/src/routes/aliases.rs
Normal file
436
warzone/crates/warzone-server/src/routes/aliases.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Alias expires after 365 days of inactivity.
|
||||
const ALIAS_TTL_SECS: i64 = 365 * 24 * 3600;
|
||||
/// Grace period after expiry: 30 days before someone else can claim.
|
||||
const GRACE_PERIOD_SECS: i64 = 30 * 24 * 3600;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/alias/register", post(register_alias))
|
||||
.route("/alias/recover", post(recover_alias))
|
||||
.route("/alias/renew", post(renew_alias))
|
||||
.route("/alias/resolve/:name", get(resolve_alias))
|
||||
.route("/alias/list", get(list_aliases))
|
||||
.route("/alias/whois/:fingerprint", get(reverse_lookup))
|
||||
.route("/alias/unregister", post(unregister_alias))
|
||||
.route("/alias/admin-remove", post(admin_remove_alias))
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn normalize_alias(name: &str) -> String {
|
||||
name.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn now_ts() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
fn gen_recovery_key() -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Stored record for an alias.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct AliasRecord {
|
||||
alias: String,
|
||||
fingerprint: String,
|
||||
recovery_key: String,
|
||||
registered_at: i64,
|
||||
last_active: i64,
|
||||
}
|
||||
|
||||
impl AliasRecord {
|
||||
fn is_expired(&self) -> bool {
|
||||
now_ts() - self.last_active > ALIAS_TTL_SECS
|
||||
}
|
||||
|
||||
fn is_past_grace(&self) -> bool {
|
||||
now_ts() - self.last_active > ALIAS_TTL_SECS + GRACE_PERIOD_SECS
|
||||
}
|
||||
|
||||
fn expires_in_days(&self) -> i64 {
|
||||
let remaining = (self.last_active + ALIAS_TTL_SECS) - now_ts();
|
||||
remaining / 86400
|
||||
}
|
||||
}
|
||||
|
||||
fn load_alias_record(db: &sled::Tree, alias: &str) -> Option<AliasRecord> {
|
||||
db.get(format!("rec:{}", alias).as_bytes())
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|data| serde_json::from_slice(&data).ok())
|
||||
}
|
||||
|
||||
fn save_alias_record(db: &sled::Tree, record: &AliasRecord) -> anyhow::Result<()> {
|
||||
let data = serde_json::to_vec(record)?;
|
||||
db.insert(format!("rec:{}", record.alias).as_bytes(), data)?;
|
||||
// Forward + reverse index
|
||||
db.insert(format!("a:{}", record.alias).as_bytes(), record.fingerprint.as_bytes())?;
|
||||
db.insert(format!("fp:{}", record.fingerprint).as_bytes(), record.alias.as_bytes())?;
|
||||
db.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_alias_record(db: &sled::Tree, record: &AliasRecord) -> anyhow::Result<()> {
|
||||
db.remove(format!("rec:{}", record.alias).as_bytes())?;
|
||||
db.remove(format!("a:{}", record.alias).as_bytes())?;
|
||||
db.remove(format!("fp:{}", record.fingerprint).as_bytes())?;
|
||||
db.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterRequest {
|
||||
alias: String,
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// Register an alias. Returns a recovery key on first registration.
|
||||
/// - One alias per fingerprint
|
||||
/// - 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>> {
|
||||
let alias = normalize_alias(&req.alias);
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
if alias.is_empty() || alias.len() > 32 {
|
||||
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 {
|
||||
// Same person — renew
|
||||
let mut updated = existing;
|
||||
updated.last_active = now_ts();
|
||||
save_alias_record(&state.db.aliases, &updated)?;
|
||||
return Ok(Json(serde_json::json!({
|
||||
"ok": true, "alias": alias, "fingerprint": fp,
|
||||
"renewed": true, "expires_in_days": updated.expires_in_days()
|
||||
})));
|
||||
}
|
||||
|
||||
if !existing.is_past_grace() {
|
||||
// Still active or in grace period — can't take it
|
||||
if existing.is_expired() {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"error": "alias expired but in grace period — use recovery key or wait",
|
||||
"grace_ends_in_days": (existing.last_active + ALIAS_TTL_SECS + GRACE_PERIOD_SECS - now_ts()) / 86400
|
||||
})));
|
||||
}
|
||||
return Ok(Json(serde_json::json!({ "error": "alias already taken" })));
|
||||
}
|
||||
|
||||
// Past grace period — clean up old record
|
||||
tracing::info!("Alias '{}' expired past grace, releasing from {}", alias, existing.fingerprint);
|
||||
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();
|
||||
if let Some(old_record) = load_alias_record(&state.db.aliases, &old_alias) {
|
||||
delete_alias_record(&state.db.aliases, &old_record)?;
|
||||
tracing::info!("Removed old alias '{}' for {}", old_alias, fp);
|
||||
}
|
||||
}
|
||||
|
||||
let recovery_key = gen_recovery_key();
|
||||
let record = AliasRecord {
|
||||
alias: alias.clone(),
|
||||
fingerprint: fp.clone(),
|
||||
recovery_key: recovery_key.clone(),
|
||||
registered_at: now_ts(),
|
||||
last_active: now_ts(),
|
||||
};
|
||||
save_alias_record(&state.db.aliases, &record)?;
|
||||
|
||||
tracing::info!("Alias '{}' registered for {}", alias, fp);
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"alias": alias,
|
||||
"fingerprint": fp,
|
||||
"recovery_key": recovery_key,
|
||||
"expires_in_days": record.expires_in_days(),
|
||||
"IMPORTANT": "Save your recovery key! It's the only way to reclaim this alias if you lose access."
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RecoverRequest {
|
||||
alias: String,
|
||||
recovery_key: String,
|
||||
new_fingerprint: String,
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let alias = normalize_alias(&req.alias);
|
||||
let new_fp = normalize_fp(&req.new_fingerprint);
|
||||
|
||||
let record = match load_alias_record(&state.db.aliases, &alias) {
|
||||
Some(r) => r,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "alias not found" }))),
|
||||
};
|
||||
|
||||
if record.recovery_key != req.recovery_key {
|
||||
tracing::warn!("Failed recovery attempt for alias '{}'", alias);
|
||||
return Ok(Json(serde_json::json!({ "error": "invalid recovery key" })));
|
||||
}
|
||||
|
||||
// Delete old mappings
|
||||
delete_alias_record(&state.db.aliases, &record)?;
|
||||
|
||||
// Remove any existing alias for the new fingerprint
|
||||
if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", new_fp).as_bytes())? {
|
||||
let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string();
|
||||
if let Some(old_record) = load_alias_record(&state.db.aliases, &old_alias) {
|
||||
delete_alias_record(&state.db.aliases, &old_record)?;
|
||||
}
|
||||
}
|
||||
|
||||
let new_recovery_key = gen_recovery_key();
|
||||
let new_record = AliasRecord {
|
||||
alias: alias.clone(),
|
||||
fingerprint: new_fp.clone(),
|
||||
recovery_key: new_recovery_key.clone(),
|
||||
registered_at: now_ts(),
|
||||
last_active: now_ts(),
|
||||
};
|
||||
save_alias_record(&state.db.aliases, &new_record)?;
|
||||
|
||||
tracing::info!("Alias '{}' recovered and transferred to {}", alias, new_fp);
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"alias": alias,
|
||||
"fingerprint": new_fp,
|
||||
"new_recovery_key": new_recovery_key,
|
||||
"IMPORTANT": "Your recovery key has been rotated. Save the new one!"
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RenewRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
let alias = match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||
Some(data) => String::from_utf8_lossy(&data).to_string(),
|
||||
None => return Ok(Json(serde_json::json!({ "alias": null }))),
|
||||
};
|
||||
|
||||
if let Some(mut record) = load_alias_record(&state.db.aliases, &alias) {
|
||||
record.last_active = now_ts();
|
||||
save_alias_record(&state.db.aliases, &record)?;
|
||||
return Ok(Json(serde_json::json!({
|
||||
"ok": true, "alias": alias, "expires_in_days": record.expires_in_days()
|
||||
})));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "alias": null })))
|
||||
}
|
||||
|
||||
/// Resolve an alias to a fingerprint.
|
||||
async fn resolve_alias(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let alias = normalize_alias(&name);
|
||||
|
||||
match load_alias_record(&state.db.aliases, &alias) {
|
||||
Some(record) => {
|
||||
if record.is_expired() {
|
||||
Ok(Json(serde_json::json!({
|
||||
"alias": alias,
|
||||
"fingerprint": record.fingerprint,
|
||||
"expired": true,
|
||||
"warning": "this alias is expired and may be reclaimed"
|
||||
})))
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({
|
||||
"alias": alias,
|
||||
"fingerprint": record.fingerprint,
|
||||
"expires_in_days": record.expires_in_days()
|
||||
})))
|
||||
}
|
||||
}
|
||||
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" })))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse lookup: fingerprint → alias.
|
||||
async fn reverse_lookup(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&fingerprint);
|
||||
|
||||
match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||
Some(data) => {
|
||||
let alias = String::from_utf8_lossy(&data).to_string();
|
||||
if let Some(record) = load_alias_record(&state.db.aliases, &alias) {
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"alias": alias,
|
||||
"expired": record.is_expired(),
|
||||
"expires_in_days": record.expires_in_days()
|
||||
})))
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": alias })))
|
||||
}
|
||||
}
|
||||
None => Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": null }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all aliases.
|
||||
async fn list_aliases(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let mut aliases: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for item in state.db.aliases.scan_prefix(b"rec:") {
|
||||
if let Ok((_, data)) = item {
|
||||
if let Ok(record) = serde_json::from_slice::<AliasRecord>(&data) {
|
||||
aliases.push(serde_json::json!({
|
||||
"alias": record.alias,
|
||||
"fingerprint": record.fingerprint,
|
||||
"expired": record.is_expired(),
|
||||
"expires_in_days": record.expires_in_days(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "aliases": aliases, "count": aliases.len() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UnregisterRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
let alias = match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||
Some(data) => String::from_utf8_lossy(&data).to_string(),
|
||||
None => return Ok(Json(serde_json::json!({ "error": "no alias registered" }))),
|
||||
};
|
||||
|
||||
if let Some(record) = load_alias_record(&state.db.aliases, &alias) {
|
||||
if record.fingerprint != fp {
|
||||
return Ok(Json(serde_json::json!({ "error": "not your alias" })));
|
||||
}
|
||||
delete_alias_record(&state.db.aliases, &record)?;
|
||||
tracing::info!("Alias '{}' unregistered by {}", alias, fp);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "removed": alias })))
|
||||
}
|
||||
|
||||
/// Admin password (set via WARZONE_ADMIN_PASSWORD env var, defaults to "admin").
|
||||
fn admin_password() -> String {
|
||||
std::env::var("WARZONE_ADMIN_PASSWORD").unwrap_or_else(|_| "admin".to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AdminRemoveRequest {
|
||||
alias: String,
|
||||
admin_password: String,
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
if req.admin_password != admin_password() {
|
||||
return Ok(Json(serde_json::json!({ "error": "invalid admin password" })));
|
||||
}
|
||||
|
||||
let alias = normalize_alias(&req.alias);
|
||||
if let Some(record) = load_alias_record(&state.db.aliases, &alias) {
|
||||
delete_alias_record(&state.db.aliases, &record)?;
|
||||
tracing::info!("Alias '{}' removed by admin", alias);
|
||||
Ok(Json(serde_json::json!({ "ok": true, "removed": alias })))
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({ "error": "alias not found" })))
|
||||
}
|
||||
}
|
||||
224
warzone/crates/warzone-server/src/routes/auth.rs
Normal file
224
warzone/crates/warzone-server/src/routes/auth.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
//! Challenge-response authentication.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. Client: POST /v1/auth/challenge { fingerprint }
|
||||
//! 2. Server: returns { challenge: random_hex, expires_at }
|
||||
//! 3. Client: POST /v1/auth/verify { fingerprint, challenge, signature }
|
||||
//! (signature = Ed25519 sign the challenge bytes with identity key)
|
||||
//! 4. Server: verifies signature against stored public key, returns { token }
|
||||
//! 5. Client: includes `Authorization: Bearer <token>` on subsequent requests
|
||||
//!
|
||||
//! Token is valid for 7 days. Server renews on activity.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Token validity: 7 days.
|
||||
const TOKEN_TTL_SECS: i64 = 7 * 24 * 3600;
|
||||
/// Challenge validity: 60 seconds.
|
||||
const CHALLENGE_TTL_SECS: i64 = 60;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/auth/challenge", post(create_challenge))
|
||||
.route("/auth/verify", post(verify_challenge))
|
||||
.route("/auth/validate", post(validate_token_endpoint))
|
||||
}
|
||||
|
||||
fn now_ts() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
fn random_hex(len: usize) -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = vec![0u8; len];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Pending challenges (fingerprint → (challenge_hex, expires_at)).
|
||||
/// In production this would be in the DB, but for Phase 1 in-memory is fine.
|
||||
static CHALLENGES: std::sync::LazyLock<Mutex<HashMap<String, (String, i64)>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChallengeRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
async fn create_challenge(
|
||||
Json(req): Json<ChallengeRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
let challenge = random_hex(32);
|
||||
let expires_at = now_ts() + CHALLENGE_TTL_SECS;
|
||||
|
||||
CHALLENGES.lock().unwrap().insert(fp.clone(), (challenge.clone(), expires_at));
|
||||
|
||||
tracing::info!("Challenge issued for {}", fp);
|
||||
Ok(Json(serde_json::json!({
|
||||
"challenge": challenge,
|
||||
"expires_at": expires_at,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VerifyRequest {
|
||||
fingerprint: String,
|
||||
challenge: String,
|
||||
signature: String, // hex-encoded Ed25519 signature
|
||||
}
|
||||
|
||||
async fn verify_challenge(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VerifyRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
// Check challenge exists and hasn't expired
|
||||
let stored = {
|
||||
let mut challenges = CHALLENGES.lock().unwrap();
|
||||
challenges.remove(&fp)
|
||||
};
|
||||
|
||||
let (expected_challenge, expires_at) = match stored {
|
||||
Some(c) => c,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "no pending challenge" }))),
|
||||
};
|
||||
|
||||
if now_ts() > expires_at {
|
||||
return Ok(Json(serde_json::json!({ "error": "challenge expired" })));
|
||||
}
|
||||
|
||||
if req.challenge != expected_challenge {
|
||||
return Ok(Json(serde_json::json!({ "error": "challenge mismatch" })));
|
||||
}
|
||||
|
||||
// Get stored public key bundle to extract Ed25519 verifying key
|
||||
let bundle_bytes = match state.db.keys.get(fp.as_bytes())? {
|
||||
Some(b) => b.to_vec(),
|
||||
None => return Ok(Json(serde_json::json!({ "error": "fingerprint not registered" }))),
|
||||
};
|
||||
|
||||
// Try to deserialize as bincode PreKeyBundle (CLI client)
|
||||
let identity_key = if let Ok(bundle) = bincode::deserialize::<warzone_protocol::prekey::PreKeyBundle>(&bundle_bytes) {
|
||||
bundle.identity_key
|
||||
} else {
|
||||
// Web client stores JSON — can't do Ed25519 verify. Accept for now.
|
||||
// Phase 2: web client uses WASM for proper Ed25519.
|
||||
let token = random_hex(32);
|
||||
let token_expires = now_ts() + TOKEN_TTL_SECS;
|
||||
state.db.tokens.insert(
|
||||
token.as_bytes(),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"expires_at": token_expires,
|
||||
}))?.as_slice(),
|
||||
)?;
|
||||
tracing::info!("Token issued for {} (web client, no sig verify)", fp);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"token": token,
|
||||
"expires_at": token_expires,
|
||||
})));
|
||||
};
|
||||
|
||||
// Verify Ed25519 signature
|
||||
let sig_bytes = hex::decode(&req.signature)
|
||||
.map_err(|_| anyhow::anyhow!("invalid signature hex"))?;
|
||||
|
||||
let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&identity_key)
|
||||
.map_err(|_| anyhow::anyhow!("invalid identity key"))?;
|
||||
|
||||
let signature = ed25519_dalek::Signature::from_slice(&sig_bytes)
|
||||
.map_err(|_| anyhow::anyhow!("invalid signature format"))?;
|
||||
|
||||
let challenge_bytes = hex::decode(&req.challenge)
|
||||
.map_err(|_| anyhow::anyhow!("invalid challenge hex"))?;
|
||||
|
||||
use ed25519_dalek::Verifier;
|
||||
verifying_key
|
||||
.verify(&challenge_bytes, &signature)
|
||||
.map_err(|_| anyhow::anyhow!("signature verification failed"))?;
|
||||
|
||||
// Issue token
|
||||
let token = random_hex(32);
|
||||
let token_expires = now_ts() + TOKEN_TTL_SECS;
|
||||
state.db.tokens.insert(
|
||||
token.as_bytes(),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"expires_at": token_expires,
|
||||
}))?.as_slice(),
|
||||
)?;
|
||||
|
||||
tracing::info!("Token issued for {} (Ed25519 verified)", fp);
|
||||
Ok(Json(serde_json::json!({
|
||||
"token": token,
|
||||
"expires_at": token_expires,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Validate a bearer token. Returns the fingerprint if valid.
|
||||
pub fn validate_token(db: &sled::Tree, token: &str) -> Option<String> {
|
||||
let data = db.get(token.as_bytes()).ok()??;
|
||||
let val: serde_json::Value = serde_json::from_slice(&data).ok()?;
|
||||
let expires = val.get("expires_at")?.as_i64()?;
|
||||
if now_ts() > expires {
|
||||
let _ = db.remove(token.as_bytes());
|
||||
return None;
|
||||
}
|
||||
val.get("fingerprint")?.as_str().map(String::from)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ValidateRequest {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// External token validation endpoint — used by WarzonePhone and other services
|
||||
/// to verify that a bearer token is valid and get the associated fingerprint.
|
||||
///
|
||||
/// POST /v1/auth/validate { "token": "..." }
|
||||
/// Returns: { "valid": true, "fingerprint": "...", "expires_at": ... }
|
||||
/// or: { "valid": false }
|
||||
async fn validate_token_endpoint(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ValidateRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
match validate_token(&state.db.tokens, &req.token) {
|
||||
Some(fingerprint) => {
|
||||
// Also resolve alias if available
|
||||
let alias = state.db.aliases.get(format!("fp:{}", fingerprint).as_bytes())
|
||||
.ok().flatten()
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string());
|
||||
|
||||
// Get Ethereum address if we have the bundle
|
||||
let eth_address: Option<String> = None; // Would need seed, which server doesn't have
|
||||
|
||||
tracing::info!("Token validated for {}", fingerprint);
|
||||
Json(serde_json::json!({
|
||||
"valid": true,
|
||||
"fingerprint": fingerprint,
|
||||
"alias": alias,
|
||||
"eth_address": eth_address,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
Json(serde_json::json!({ "valid": false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
1072
warzone/crates/warzone-server/src/routes/bot.rs
Normal file
1072
warzone/crates/warzone-server/src/routes/bot.rs
Normal file
File diff suppressed because it is too large
Load Diff
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal 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,
|
||||
})))
|
||||
}
|
||||
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal 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,
|
||||
})))
|
||||
}
|
||||
161
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
161
warzone/crates/warzone-server/src/routes/federation.rs
Normal 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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
54
warzone/crates/warzone-server/src/routes/friends.rs
Normal file
54
warzone/crates/warzone-server/src/routes/friends.rs
Normal 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 })))
|
||||
}
|
||||
307
warzone/crates/warzone-server/src/routes/groups.rs
Normal file
307
warzone/crates/warzone-server/src/routes/groups.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/groups", get(list_groups))
|
||||
.route("/groups/create", post(create_group))
|
||||
.route("/groups/:name", get(get_group))
|
||||
.route("/groups/:name/join", post(join_group))
|
||||
.route("/groups/:name/send", post(send_to_group))
|
||||
.route("/groups/:name/leave", post(leave_group))
|
||||
.route("/groups/:name/kick", post(kick_member))
|
||||
.route("/groups/:name/members", get(get_members))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct GroupInfo {
|
||||
name: String,
|
||||
creator: String,
|
||||
members: Vec<String>, // fingerprints
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateRequest {
|
||||
name: String,
|
||||
creator: String, // fingerprint
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JoinRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// A group message: the client sends one ciphertext per member.
|
||||
/// Server fans out each entry to the respective member's message queue.
|
||||
#[derive(Deserialize)]
|
||||
struct GroupSendRequest {
|
||||
from: String,
|
||||
/// Each entry is an encrypted message destined for one member.
|
||||
/// The client encrypts separately for each recipient.
|
||||
messages: Vec<MemberMessage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MemberMessage {
|
||||
to: String, // member fingerprint
|
||||
message: Vec<u8>, // encrypted payload (same format as 1:1 messages)
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn load_group(db: &sled::Tree, name: &str) -> Option<GroupInfo> {
|
||||
db.get(name.as_bytes())
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|data| serde_json::from_slice(&data).ok())
|
||||
}
|
||||
|
||||
fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> {
|
||||
let data = serde_json::to_vec(group)?;
|
||||
db.insert(group.name.as_bytes(), data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let name = req.name.trim().to_lowercase();
|
||||
if name.is_empty() {
|
||||
return Ok(Json(serde_json::json!({ "error": "name required" })));
|
||||
}
|
||||
|
||||
if load_group(&state.db.groups, &name).is_some() {
|
||||
return Ok(Json(serde_json::json!({ "error": "group already exists" })));
|
||||
}
|
||||
|
||||
let creator = normalize_fp(&req.creator);
|
||||
let group = GroupInfo {
|
||||
name: name.clone(),
|
||||
creator: creator.clone(),
|
||||
members: vec![creator],
|
||||
};
|
||||
save_group(&state.db.groups, &group)?;
|
||||
tracing::info!("Group '{}' created", name);
|
||||
Ok(Json(serde_json::json!({ "ok": true, "name": name })))
|
||||
}
|
||||
|
||||
async fn join_group(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<JoinRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
// Auto-create if group doesn't exist
|
||||
let mut group = match load_group(&state.db.groups, &name) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
let g = GroupInfo {
|
||||
name: name.clone(),
|
||||
creator: fp.clone(),
|
||||
members: vec![],
|
||||
};
|
||||
tracing::info!("Group '{}' auto-created by {}", name, fp);
|
||||
g
|
||||
}
|
||||
};
|
||||
|
||||
if !group.members.contains(&fp) {
|
||||
group.members.push(fp.clone());
|
||||
tracing::info!("{} joined group '{}' ({} members)", fp, name, group.members.len());
|
||||
}
|
||||
save_group(&state.db.groups, &group)?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "members": group.members.len() })))
|
||||
}
|
||||
|
||||
async fn get_group(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
match load_group(&state.db.groups, &name) {
|
||||
Some(group) => Ok(Json(serde_json::json!({
|
||||
"name": group.name,
|
||||
"creator": group.creator,
|
||||
"members": group.members,
|
||||
"count": group.members.len(),
|
||||
}))),
|
||||
None => Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_groups(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let groups: Vec<serde_json::Value> = state
|
||||
.db
|
||||
.groups
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.ok().and_then(|(_, data)| {
|
||||
serde_json::from_slice::<GroupInfo>(&data).ok().map(|g| {
|
||||
serde_json::json!({
|
||||
"name": g.name,
|
||||
"members": g.members.len(),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!({ "groups": groups })))
|
||||
}
|
||||
|
||||
/// Fan-out: client sends per-member encrypted messages, server puts each
|
||||
/// in the respective member's queue. This reuses the existing message
|
||||
/// 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>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let group = match load_group(&state.db.groups, &name) {
|
||||
Some(g) => g,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
|
||||
let from = normalize_fp(&req.from);
|
||||
if !group.members.contains(&from) {
|
||||
return Ok(Json(serde_json::json!({ "error": "not a member of this group" })));
|
||||
}
|
||||
|
||||
let mut delivered = 0;
|
||||
for msg in &req.messages {
|
||||
let to = normalize_fp(&msg.to);
|
||||
if group.members.contains(&to) {
|
||||
// Try WebSocket push first (instant), fall back to DB queue
|
||||
if state.push_to_client(&to, &msg.message).await {
|
||||
tracing::debug!("Group '{}': pushed to {} via WS", name, to);
|
||||
} else {
|
||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||
state.db.messages.insert(key.as_bytes(), msg.message.as_slice())?;
|
||||
}
|
||||
delivered += 1;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Group '{}': {} sent {} messages to {} members",
|
||||
name,
|
||||
from,
|
||||
delivered,
|
||||
group.members.len()
|
||||
);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "delivered": delivered })))
|
||||
}
|
||||
|
||||
async fn leave_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<JoinRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
let mut group = match load_group(&state.db.groups, &name) {
|
||||
Some(g) => g,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
|
||||
group.members.retain(|m| m != &fp);
|
||||
save_group(&state.db.groups, &group)?;
|
||||
tracing::info!("{} left group '{}' ({} remaining)", fp, name, group.members.len());
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "remaining": group.members.len() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KickRequest {
|
||||
fingerprint: String, // who is doing the kicking (must be creator)
|
||||
target: String, // who to kick
|
||||
}
|
||||
|
||||
async fn kick_member(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<KickRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
let target = normalize_fp(&req.target);
|
||||
|
||||
let mut group = match load_group(&state.db.groups, &name) {
|
||||
Some(g) => g,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
|
||||
if group.creator != fp {
|
||||
return Ok(Json(serde_json::json!({ "error": "only the creator can kick members" })));
|
||||
}
|
||||
|
||||
if target == fp {
|
||||
return Ok(Json(serde_json::json!({ "error": "cannot kick yourself" })));
|
||||
}
|
||||
|
||||
let before = group.members.len();
|
||||
group.members.retain(|m| m != &target);
|
||||
if group.members.len() == before {
|
||||
return Ok(Json(serde_json::json!({ "error": "target is not a member" })));
|
||||
}
|
||||
|
||||
save_group(&state.db.groups, &group)?;
|
||||
tracing::info!("{} kicked {} from group '{}'", fp, target, name);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "kicked": target, "remaining": group.members.len() })))
|
||||
}
|
||||
|
||||
async fn get_members(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let group = match load_group(&state.db.groups, &name) {
|
||||
Some(g) => g,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
|
||||
// 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,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"name": group.name,
|
||||
"members": members_info,
|
||||
"count": members_info.len(),
|
||||
"online_count": online_count,
|
||||
})))
|
||||
}
|
||||
@@ -10,13 +10,44 @@ use crate::state::AppState;
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/keys/register", post(register_keys))
|
||||
.route("/keys/{fingerprint}", get(get_bundle))
|
||||
.route("/keys/replenish", post(replenish_otpks))
|
||||
.route("/keys/list", get(list_keys))
|
||||
.route("/keys/:fingerprint", get(get_bundle))
|
||||
.route("/keys/:fingerprint/otpk-count", get(otpk_count))
|
||||
.route("/keys/:fingerprint/devices", get(list_devices))
|
||||
}
|
||||
|
||||
/// Debug endpoint: list all registered fingerprints.
|
||||
async fn list_keys(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let keys: Vec<String> = state
|
||||
.db
|
||||
.keys
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.ok()
|
||||
.and_then(|(k, _)| String::from_utf8(k.to_vec()).ok())
|
||||
})
|
||||
.collect();
|
||||
tracing::info!("Listed {} registered keys", keys.len());
|
||||
Json(serde_json::json!({ "keys": keys, "count": keys.len() }))
|
||||
}
|
||||
|
||||
/// Normalize fingerprint: strip colons, lowercase.
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterRequest {
|
||||
fingerprint: String,
|
||||
bundle: Vec<u8>, // bincode-serialized PreKeyBundle
|
||||
#[serde(default)]
|
||||
device_id: Option<String>,
|
||||
bundle: Vec<u8>,
|
||||
#[serde(default)]
|
||||
eth_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -25,10 +56,31 @@ struct RegisterResponse {
|
||||
}
|
||||
|
||||
async fn register_keys(
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Json<RegisterResponse> {
|
||||
let _ = state.db.keys.insert(req.fingerprint.as_bytes(), req.bundle);
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
let device_id = req.device_id.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
// Store bundle keyed by fingerprint (primary, used for lookup)
|
||||
let _ = state.db.keys.insert(fp.as_bytes(), req.bundle.clone());
|
||||
|
||||
// Also store per-device: device:<fp>:<device_id> → bundle
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -36,11 +88,112 @@ async fn get_bundle(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
|
||||
match state.db.keys.get(fingerprint.as_bytes()) {
|
||||
Ok(Some(data)) => Ok(Json(serde_json::json!({
|
||||
let key = normalize_fp(&fingerprint);
|
||||
tracing::info!("get_bundle: raw path='{}', normalized='{}'", fingerprint, key);
|
||||
|
||||
// Debug: list what's in the DB
|
||||
let all_keys: Vec<String> = state.db.keys.iter()
|
||||
.filter_map(|r| r.ok().and_then(|(k, _)| String::from_utf8(k.to_vec()).ok()))
|
||||
.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 {} (local={})", data.len(), key, is_local);
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
|
||||
}))),
|
||||
_ => Err(axum::http::StatusCode::NOT_FOUND),
|
||||
})))
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!("get_bundle: NOT FOUND for key '{}'", key);
|
||||
Err(axum::http::StatusCode::NOT_FOUND)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_bundle: DB error: {}", e);
|
||||
Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check how many one-time pre-keys remain for a fingerprint.
|
||||
async fn otpk_count(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let fp = normalize_fp(&fingerprint);
|
||||
let prefix = format!("otpk:{}:", fp);
|
||||
let count = state.db.keys.scan_prefix(prefix.as_bytes()).count();
|
||||
Json(serde_json::json!({ "fingerprint": fp, "otpk_count": count }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReplenishRequest {
|
||||
fingerprint: String,
|
||||
/// One-time pre-keys: list of {id, public_key_hex}
|
||||
otpks: Vec<OtpkEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OtpkEntry {
|
||||
id: u32,
|
||||
public_key: String, // hex-encoded 32-byte X25519 public key
|
||||
}
|
||||
|
||||
/// Upload additional one-time pre-keys.
|
||||
async fn replenish_otpks(
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ReplenishRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
let mut stored = 0;
|
||||
|
||||
for otpk in &req.otpks {
|
||||
let key = format!("otpk:{}:{}", fp, otpk.id);
|
||||
let _ = state.db.keys.insert(key.as_bytes(), otpk.public_key.as_bytes());
|
||||
stored += 1;
|
||||
}
|
||||
|
||||
let prefix = format!("otpk:{}:", fp);
|
||||
let total = state.db.keys.scan_prefix(prefix.as_bytes()).count();
|
||||
|
||||
tracing::info!("Replenished {} OTPKs for {} (total: {})", stored, fp, total);
|
||||
Json(serde_json::json!({ "ok": true, "stored": stored, "total": total }))
|
||||
}
|
||||
|
||||
/// List all registered devices for a fingerprint.
|
||||
async fn list_devices(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let fp = normalize_fp(&fingerprint);
|
||||
let prefix = format!("device:{}:", fp);
|
||||
let devices: Vec<String> = state.db.keys.scan_prefix(prefix.as_bytes())
|
||||
.filter_map(|item| {
|
||||
item.ok().and_then(|(k, _)| {
|
||||
let key_str = String::from_utf8_lossy(&k).to_string();
|
||||
// key format: device:<fp>:<device_id>
|
||||
key_str.rsplit(':').next().map(|s| s.to_string())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Json(serde_json::json!({ "fingerprint": fp, "devices": devices, "count": devices.len() }))
|
||||
}
|
||||
|
||||
@@ -4,58 +4,142 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use warzone_protocol::message::WireMessage;
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 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) = warzone_protocol::message::deserialize_envelope(data) {
|
||||
match wire {
|
||||
WireMessage::KeyExchange { id, .. } => Some(id),
|
||||
WireMessage::Message { id, .. } => Some(id),
|
||||
WireMessage::FileHeader { id, .. } => Some(id),
|
||||
WireMessage::FileChunk { id, .. } => Some(id),
|
||||
WireMessage::Receipt { message_id, .. } => Some(message_id),
|
||||
WireMessage::GroupSenderKey { id, .. } => Some(id),
|
||||
WireMessage::SenderKeyDistribution { sender_fingerprint, group_name, .. } => {
|
||||
Some(format!("skd:{}:{}", sender_fingerprint, group_name))
|
||||
}
|
||||
WireMessage::CallSignal { id, .. } => Some(id),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch the alias TTL for a fingerprint (renew on authenticated action).
|
||||
pub fn renew_alias_ttl(db: &sled::Tree, fp: &str) {
|
||||
let alias_key = format!("fp:{}", fp);
|
||||
if let Ok(Some(alias_bytes)) = db.get(alias_key.as_bytes()) {
|
||||
let alias = String::from_utf8_lossy(&alias_bytes).to_string();
|
||||
let rec_key = format!("rec:{}", alias);
|
||||
if let Ok(Some(rec_data)) = db.get(rec_key.as_bytes()) {
|
||||
if let Ok(mut record) = serde_json::from_slice::<serde_json::Value>(&rec_data) {
|
||||
if let Some(obj) = record.as_object_mut() {
|
||||
obj.insert("last_active".into(), serde_json::json!(chrono::Utc::now().timestamp()));
|
||||
if let Ok(updated) = serde_json::to_vec(&record) {
|
||||
let _ = db.insert(rec_key.as_bytes(), updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/messages/send", post(send_message))
|
||||
.route("/messages/poll/{fingerprint}", get(poll_messages))
|
||||
.route("/messages/{id}/ack", delete(ack_message))
|
||||
.route("/messages/poll/:fingerprint", get(poll_messages))
|
||||
.route("/messages/:id/ack", delete(ack_message))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendRequest {
|
||||
to: String,
|
||||
message: Vec<u8>, // bincode-serialized WarzoneMessage
|
||||
#[serde(default)]
|
||||
from: Option<String>,
|
||||
message: Vec<u8>,
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SendRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Append to recipient's queue
|
||||
let key = format!("queue:{}", req.to);
|
||||
let _ = state.db.messages.insert(
|
||||
format!("{}:{}", key, uuid::Uuid::new_v4()).as_bytes(),
|
||||
req.message,
|
||||
);
|
||||
Json(serde_json::json!({ "ok": true }))
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let to = normalize_fp(&req.to);
|
||||
|
||||
// Dedup: if we have already seen this message ID, silently drop it
|
||||
if let Some(msg_id) = extract_message_id(&req.message) {
|
||||
if state.dedup.check_and_insert(&msg_id) {
|
||||
tracing::debug!("Dedup: dropping duplicate message {}", msg_id);
|
||||
return Ok(Json(serde_json::json!({ "ok": true })));
|
||||
}
|
||||
}
|
||||
|
||||
let delivered = state.deliver_or_queue(&to, &req.message).await;
|
||||
if delivered {
|
||||
tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len());
|
||||
} else {
|
||||
tracing::info!("Queued message for {} ({} bytes)", to, req.message.len());
|
||||
}
|
||||
|
||||
// Renew sender's alias TTL (sending = authenticated action)
|
||||
if let Some(ref from) = req.from {
|
||||
renew_alias_ttl(&state.db.aliases, &normalize_fp(from));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Poll fetches all queued messages and deletes them from the server.
|
||||
/// This is store-and-forward: once delivered, the server drops them.
|
||||
async fn poll_messages(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Json<Vec<String>> {
|
||||
let prefix = format!("queue:{}", fingerprint);
|
||||
) -> AppResult<Json<Vec<String>>> {
|
||||
let prefix = format!("queue:{}", normalize_fp(&fingerprint));
|
||||
let mut messages = Vec::new();
|
||||
let mut keys_to_delete = Vec::new();
|
||||
|
||||
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
||||
if let Ok((_, value)) = item {
|
||||
let (key, value) = item?;
|
||||
messages.push(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&value,
|
||||
));
|
||||
keys_to_delete.push(key);
|
||||
}
|
||||
|
||||
// Delete after collecting (fetch-and-delete)
|
||||
for key in &keys_to_delete {
|
||||
state.db.messages.remove(key)?;
|
||||
}
|
||||
Json(messages)
|
||||
|
||||
if !messages.is_empty() {
|
||||
tracing::info!(
|
||||
"Delivered {} message(s) to {}, deleted from queue",
|
||||
messages.len(),
|
||||
normalize_fp(&fingerprint)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(messages))
|
||||
}
|
||||
|
||||
/// Explicit ack endpoint (for future use with selective delivery).
|
||||
async fn ack_message(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Scan for and remove the message with this ID
|
||||
// In a real implementation, we'd have a proper index
|
||||
let _ = state.db.messages.remove(id.as_bytes());
|
||||
Json(serde_json::json!({ "ok": true }))
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
state.db.messages.remove(id.as_bytes())?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
mod aliases;
|
||||
pub mod auth;
|
||||
pub mod bot;
|
||||
mod calls;
|
||||
mod devices;
|
||||
mod federation;
|
||||
mod friends;
|
||||
mod groups;
|
||||
mod health;
|
||||
mod keys;
|
||||
mod messages;
|
||||
pub mod messages;
|
||||
mod presence;
|
||||
mod resolve;
|
||||
mod web;
|
||||
mod ws;
|
||||
mod wzp;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
@@ -12,6 +24,18 @@ pub fn router() -> Router<AppState> {
|
||||
.merge(health::routes())
|
||||
.merge(keys::routes())
|
||||
.merge(messages::routes())
|
||||
.merge(groups::routes())
|
||||
.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)
|
||||
|
||||
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal 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 })))
|
||||
}
|
||||
136
warzone/crates/warzone-server/src/routes/resolve.rs
Normal file
136
warzone/crates/warzone-server/src/routes/resolve.rs
Normal 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
287
warzone/crates/warzone-server/src/routes/ws.rs
Normal file
287
warzone/crates/warzone-server/src/routes/ws.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
//! WebSocket endpoint for real-time message delivery.
|
||||
//!
|
||||
//! Protocol:
|
||||
//! 1. Client connects to /v1/ws/:fingerprint
|
||||
//! 2. Server sends any queued messages (from DB)
|
||||
//! 3. Server pushes new messages in real-time
|
||||
//! 4. Client sends messages as binary WireMessage frames
|
||||
//! 5. Server routes to recipient's WS or queues in DB
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
Path, State, WebSocketUpgrade,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use warzone_protocol::message::WireMessage;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 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) = warzone_protocol::message::deserialize_envelope(data) {
|
||||
match wire {
|
||||
WireMessage::KeyExchange { id, .. } => Some(id),
|
||||
WireMessage::Message { id, .. } => Some(id),
|
||||
WireMessage::FileHeader { id, .. } => Some(id),
|
||||
WireMessage::FileChunk { id, .. } => Some(id),
|
||||
WireMessage::Receipt { message_id, .. } => Some(message_id),
|
||||
WireMessage::GroupSenderKey { id, .. } => Some(id),
|
||||
WireMessage::SenderKeyDistribution { sender_fingerprint, group_name, .. } => {
|
||||
Some(format!("skd:{}:{}", sender_fingerprint, group_name))
|
||||
}
|
||||
WireMessage::CallSignal { id, .. } => Some(id),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/ws/:fingerprint", get(ws_handler))
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let fp = normalize_fp(&fingerprint);
|
||||
tracing::info!("WS upgrade request from {}", fp);
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, fp))
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String) {
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// Register for push delivery
|
||||
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 (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 {
|
||||
let _ = state.db.messages.remove(key);
|
||||
}
|
||||
if !keys_to_delete.is_empty() {
|
||||
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)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ws_tx
|
||||
});
|
||||
|
||||
// Handle incoming messages from client
|
||||
let state_clone = state.clone();
|
||||
let fp_clone2 = fingerprint.clone();
|
||||
let mut recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
match msg {
|
||||
Message::Binary(data) => {
|
||||
// Parse as a simple { to: "fp", message: bytes } JSON
|
||||
// Or just raw WireMessage bytes with a 32-byte fingerprint prefix
|
||||
// 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 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
|
||||
if let Some(msg_id) = extract_message_id(message) {
|
||||
if state_clone.dedup.check_and_insert(&msg_id) {
|
||||
tracing::debug!("WS dedup: dropping duplicate binary message {}", msg_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Message::Text(text) => {
|
||||
// JSON format: {"to": "fp", "message": [bytes]}
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let to_fp = normalize_fp(to);
|
||||
if let Some(msg_arr) = parsed.get("message").and_then(|v| v.as_array()) {
|
||||
let message: Vec<u8> = msg_arr.iter()
|
||||
.filter_map(|v| v.as_u64().map(|n| n as u8))
|
||||
.collect();
|
||||
|
||||
// Dedup: skip if we already processed this message ID
|
||||
if let Some(msg_id) = extract_message_id(&message) {
|
||||
if state_clone.dedup.check_and_insert(&msg_id) {
|
||||
tracing::debug!("WS dedup: dropping duplicate JSON message {}", msg_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
&state_clone.db.aliases, &fp_clone2,
|
||||
);
|
||||
|
||||
tracing::debug!("WS {}: routed JSON message to {}", fp_clone2, to_fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either task to finish
|
||||
tokio::select! {
|
||||
_ = &mut push_task => {
|
||||
recv_task.abort();
|
||||
}
|
||||
_ = &mut recv_task => {
|
||||
push_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister
|
||||
// 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(devices) = conns.get_mut(&fingerprint) {
|
||||
devices.retain(|d| !d.sender.is_closed());
|
||||
if devices.is_empty() {
|
||||
conns.remove(&fingerprint);
|
||||
}
|
||||
}
|
||||
tracing::info!("WS {} disconnected", fingerprint);
|
||||
}
|
||||
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal 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,
|
||||
})))
|
||||
}
|
||||
@@ -1,15 +1,414 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
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>>;
|
||||
|
||||
/// 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)]
|
||||
pub struct DedupTracker {
|
||||
seen: Arc<std::sync::Mutex<HashSet<String>>>,
|
||||
order: Arc<std::sync::Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl DedupTracker {
|
||||
pub fn new() -> Self {
|
||||
DedupTracker {
|
||||
seen: Arc::new(std::sync::Mutex::new(HashSet::with_capacity(DEDUP_CAPACITY))),
|
||||
order: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(DEDUP_CAPACITY))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this ID was already seen (i.e. it is a duplicate).
|
||||
/// If new, inserts it and evicts the oldest if over capacity.
|
||||
pub fn check_and_insert(&self, id: &str) -> bool {
|
||||
let mut seen = self.seen.lock().unwrap();
|
||||
if seen.contains(id) {
|
||||
return true; // duplicate
|
||||
}
|
||||
let mut order = self.order.lock().unwrap();
|
||||
if seen.len() >= DEDUP_CAPACITY {
|
||||
if let Some(oldest) = order.pop_front() {
|
||||
seen.remove(&oldest);
|
||||
}
|
||||
}
|
||||
seen.insert(id.to_string());
|
||||
order.push_back(id.to_string());
|
||||
false // not a duplicate
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub fn new(data_dir: &str) -> anyhow::Result<Self> {
|
||||
let db = Database::open(data_dir)?;
|
||||
Ok(AppState { db: Arc::new(db) })
|
||||
Ok(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(devices) = conns.get(fingerprint) {
|
||||
let mut delivered = false;
|
||||
for device in devices {
|
||||
if device.sender.send(message.to_vec()).is_ok() {
|
||||
delivered = true;
|
||||
}
|
||||
}
|
||||
delivered
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a WS connection for a fingerprint.
|
||||
///
|
||||
/// 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;
|
||||
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(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);
|
||||
}
|
||||
}
|
||||
|
||||
28
warzone/crates/warzone-wasm/Cargo.toml
Normal file
28
warzone/crates/warzone-wasm/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "warzone-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
warzone-protocol = { path = "../warzone-protocol" }
|
||||
wasm-bindgen = "0.2"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["console"] }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
base64.workspace = true
|
||||
hex.workspace = true
|
||||
bincode.workspace = true
|
||||
x25519-dalek.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
rand.workspace = true
|
||||
uuid = { version = "1", features = ["v4", "serde", "js"] }
|
||||
|
||||
# profile.release is set at workspace root
|
||||
792
warzone/crates/warzone-wasm/src/lib.rs
Normal file
792
warzone/crates/warzone-wasm/src/lib.rs
Normal file
@@ -0,0 +1,792 @@
|
||||
//! WASM bridge: exposes warzone-protocol to JavaScript.
|
||||
//!
|
||||
//! Gives the web client the EXACT same crypto as the CLI:
|
||||
//! X25519, ChaCha20-Poly1305, X3DH, Double Ratchet.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use warzone_protocol::identity::{IdentityKeyPair, PublicIdentity, Seed};
|
||||
use warzone_protocol::message::{ReceiptType, WireMessage};
|
||||
use warzone_protocol::prekey::{
|
||||
generate_signed_pre_key, PreKeyBundle,
|
||||
};
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::x3dh;
|
||||
use x25519_dalek::PublicKey;
|
||||
|
||||
// ── Identity ──
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmIdentity {
|
||||
seed_bytes: [u8; 32],
|
||||
#[wasm_bindgen(skip)]
|
||||
pub identity: IdentityKeyPair,
|
||||
#[wasm_bindgen(skip)]
|
||||
pub pub_id: PublicIdentity,
|
||||
// Pre-key secrets (generated once, reused for decrypt)
|
||||
spk_secret_bytes: [u8; 32],
|
||||
bundle_cache: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmIdentity {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmIdentity {
|
||||
let seed = Seed::generate();
|
||||
Self::from_seed(seed)
|
||||
}
|
||||
|
||||
pub fn from_hex_seed(hex_seed: &str) -> Result<WasmIdentity, JsValue> {
|
||||
let bytes = hex::decode(hex_seed).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
if bytes.len() != 32 { return Err(JsValue::from_str("seed must be 32 bytes")); }
|
||||
let mut seed_bytes = [0u8; 32];
|
||||
seed_bytes.copy_from_slice(&bytes);
|
||||
Ok(Self::from_seed(Seed::from_bytes(seed_bytes)))
|
||||
}
|
||||
|
||||
pub fn fingerprint(&self) -> String { self.pub_id.fingerprint.to_string() }
|
||||
pub fn seed_hex(&self) -> String { hex::encode(self.seed_bytes) }
|
||||
pub fn fingerprint_hex(&self) -> String { self.pub_id.fingerprint.to_hex() }
|
||||
|
||||
pub fn mnemonic(&self) -> String {
|
||||
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> {
|
||||
if let Some(ref cached) = self.bundle_cache {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let bundle = self.generate_bundle_internal()
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let bytes = bincode::serialize(&bundle)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
self.bundle_cache = Some(bytes.clone());
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Get the SPK secret as hex (for persistence in localStorage).
|
||||
pub fn spk_secret_hex(&self) -> String {
|
||||
hex::encode(self.spk_secret_bytes)
|
||||
}
|
||||
|
||||
/// Restore the SPK secret from hex (loaded from localStorage).
|
||||
pub fn set_spk_secret_hex(&mut self, hex: &str) -> Result<(), JsValue> {
|
||||
let bytes = hex::decode(hex).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
if bytes.len() != 32 { return Err(JsValue::from_str("SPK secret must be 32 bytes")); }
|
||||
self.spk_secret_bytes.copy_from_slice(&bytes);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl WasmIdentity {
|
||||
fn from_seed(seed: Seed) -> Self {
|
||||
let seed_bytes = seed.0;
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
|
||||
// Generate pre-keys ONCE
|
||||
let (spk_secret, _) = generate_signed_pre_key(&identity, 1);
|
||||
let spk_secret_bytes = spk_secret.to_bytes();
|
||||
|
||||
WasmIdentity {
|
||||
seed_bytes,
|
||||
identity,
|
||||
pub_id,
|
||||
spk_secret_bytes,
|
||||
bundle_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_bundle_internal(&self) -> Result<PreKeyBundle, String> {
|
||||
// Recreate SPK from stored secret
|
||||
let spk_secret = x25519_dalek::StaticSecret::from(self.spk_secret_bytes);
|
||||
let spk_public = PublicKey::from(&spk_secret);
|
||||
|
||||
// Sign the SPK public key
|
||||
use ed25519_dalek::Signer;
|
||||
let signature = self.identity.signing.sign(spk_public.as_bytes());
|
||||
|
||||
let spk = warzone_protocol::prekey::SignedPreKey {
|
||||
id: 1,
|
||||
public_key: *spk_public.as_bytes(),
|
||||
signature: signature.to_bytes().to_vec(),
|
||||
timestamp: js_sys::Date::now() as i64 / 1000,
|
||||
};
|
||||
|
||||
// No OTPKs for web client (can't store secrets for them reliably).
|
||||
// initiate() will skip DH4 when one_time_pre_key is None.
|
||||
// This is safe — OTPKs are an anti-replay optimization, not required.
|
||||
Ok(PreKeyBundle {
|
||||
identity_key: *self.pub_id.signing.as_bytes(),
|
||||
identity_encryption_key: *self.pub_id.encryption.as_bytes(),
|
||||
signed_pre_key: spk,
|
||||
one_time_pre_key: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session ──
|
||||
|
||||
#[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]
|
||||
impl WasmSession {
|
||||
pub fn initiate(
|
||||
identity: &WasmIdentity,
|
||||
their_bundle_bytes: &[u8],
|
||||
) -> Result<WasmSession, JsValue> {
|
||||
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
|
||||
.map_err(|e| JsValue::from_str(&format!("bundle: {}", e)))?;
|
||||
let result = x3dh::initiate(&identity.identity, &bundle)
|
||||
.map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?;
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encrypt_key_exchange(
|
||||
&mut self,
|
||||
identity: &WasmIdentity,
|
||||
their_bundle_bytes: &[u8],
|
||||
plaintext: &str,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
self.encrypt_key_exchange_with_id(identity, their_bundle_bytes, plaintext, &uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
pub fn encrypt_key_exchange_with_id(
|
||||
&mut self,
|
||||
identity: &WasmIdentity,
|
||||
_their_bundle_bytes: &[u8],
|
||||
plaintext: &str,
|
||||
msg_id: &str,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
// 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)))?;
|
||||
|
||||
let wire = WireMessage::KeyExchange {
|
||||
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,
|
||||
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()))
|
||||
}
|
||||
|
||||
pub fn encrypt(&mut self, identity: &WasmIdentity, plaintext: &str) -> Result<Vec<u8>, JsValue> {
|
||||
self.encrypt_with_id(identity, plaintext, &uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
pub fn encrypt_with_id(&mut self, identity: &WasmIdentity, plaintext: &str, msg_id: &str) -> Result<Vec<u8>, JsValue> {
|
||||
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
|
||||
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
||||
let wire = WireMessage::Message {
|
||||
id: msg_id.to_string(),
|
||||
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
||||
ratchet_message: encrypted,
|
||||
};
|
||||
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<String, JsValue> {
|
||||
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::deserialize_versioned(&bytes)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receipt creation ──
|
||||
|
||||
/// Create a Receipt wire message (plaintext, not encrypted).
|
||||
/// `receipt_type`: "delivered" or "read".
|
||||
/// Returns bincode-serialized bytes.
|
||||
#[wasm_bindgen]
|
||||
pub fn create_receipt(
|
||||
sender_fingerprint: &str,
|
||||
message_id: &str,
|
||||
receipt_type: &str,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
let rt = match receipt_type {
|
||||
"delivered" => ReceiptType::Delivered,
|
||||
"read" => ReceiptType::Read,
|
||||
_ => return Err(JsValue::from_str("receipt_type must be 'delivered' or 'read'")),
|
||||
};
|
||||
let wire = WireMessage::Receipt {
|
||||
sender_fingerprint: sender_fingerprint.to_string(),
|
||||
message_id: message_id.to_string(),
|
||||
receipt_type: rt,
|
||||
};
|
||||
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
// ── Self-test (verifies full encrypt/decrypt cycle within WASM) ──
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn self_test() -> Result<String, JsValue> {
|
||||
// Check randomness works
|
||||
let mut rng_test = [0u8; 8];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut rng_test);
|
||||
let rng_hex = hex::encode(rng_test);
|
||||
|
||||
// Alice
|
||||
let alice_seed = Seed::generate();
|
||||
let alice_id = alice_seed.derive_identity();
|
||||
let alice_pub = alice_id.public_identity();
|
||||
|
||||
// Bob
|
||||
let bob_seed = Seed::generate();
|
||||
let bob_id = bob_seed.derive_identity();
|
||||
let bob_pub = bob_id.public_identity();
|
||||
|
||||
// Bob's pre-key bundle
|
||||
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)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
// Alice initiates X3DH and encrypts
|
||||
let x3dh_result = x3dh::initiate(&alice_id, &bob_bundle)
|
||||
.map_err(|e| JsValue::from_str(&format!("X3DH initiate: {}", e)))?;
|
||||
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);
|
||||
let encrypted = alice_ratchet.encrypt(b"hello from WASM self-test")
|
||||
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
||||
|
||||
// Clone encrypted for later use (wire takes ownership)
|
||||
let encrypted_clone = encrypted.clone();
|
||||
|
||||
let _wire = WireMessage::KeyExchange {
|
||||
id: uuid::Uuid::new_v4().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,
|
||||
};
|
||||
// Step-by-step Bob-side decrypt (NOT using decrypt_wire_message)
|
||||
let alice_shared_hex = hex::encode(x3dh_result.shared_secret);
|
||||
|
||||
// Bob: X3DH respond
|
||||
let bob_shared = x3dh::respond(
|
||||
&bob_id, &bob_spk_secret, None,
|
||||
&alice_pub.encryption, &x3dh_result.ephemeral_public,
|
||||
).map_err(|e| JsValue::from_str(&format!("X3DH respond: {}", e)))?;
|
||||
let bob_shared_hex = hex::encode(bob_shared);
|
||||
let shared_match = alice_shared_hex == bob_shared_hex;
|
||||
|
||||
// Bob: init ratchet
|
||||
// Need a fresh copy of spk_secret (bob_spk_secret was moved into respond)
|
||||
let bob_spk_secret2 = x25519_dalek::StaticSecret::from(bob_spk_secret_bytes);
|
||||
let mut bob_ratchet = RatchetState::init_bob(bob_shared, bob_spk_secret2);
|
||||
|
||||
// Bob: decrypt
|
||||
let decrypt_result = bob_ratchet.decrypt(&encrypted_clone);
|
||||
let decrypt_text = match &decrypt_result {
|
||||
Ok(plain) => String::from_utf8_lossy(plain).to_string(),
|
||||
Err(e) => format!("DECRYPT_ERROR: {}", e),
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"rng={}, shared_match={}, alice_shared={}..., bob_shared={}..., decrypt='{}', PASS={}",
|
||||
rng_hex, shared_match, &alice_shared_hex[..16], &bob_shared_hex[..16],
|
||||
decrypt_text, decrypt_text == "hello from WASM self-test"
|
||||
))
|
||||
}
|
||||
|
||||
// ── Decrypt ──
|
||||
|
||||
/// Debug: dump what the WASM identity's bundle looks like (for comparing with CLI).
|
||||
#[wasm_bindgen]
|
||||
pub fn debug_bundle_info(identity: &mut WasmIdentity) -> Result<String, JsValue> {
|
||||
let bundle_bytes = identity.bundle_bytes()?;
|
||||
let bundle: PreKeyBundle = bincode::deserialize(&bundle_bytes)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
let spk_pub_hex = hex::encode(bundle.signed_pre_key.public_key);
|
||||
let ik_hex = hex::encode(bundle.identity_key);
|
||||
let iek_hex = hex::encode(bundle.identity_encryption_key);
|
||||
let spk_secret_hex = identity.spk_secret_hex();
|
||||
|
||||
// Verify SPK matches
|
||||
let spk_secret = x25519_dalek::StaticSecret::from(identity.spk_secret_bytes);
|
||||
let derived_pub = PublicKey::from(&spk_secret);
|
||||
let matches = *derived_pub.as_bytes() == bundle.signed_pre_key.public_key;
|
||||
|
||||
Ok(format!(
|
||||
"bundle_size={}, ik={}, iek={}, spk_pub={}, spk_secret={}, spk_matches={}",
|
||||
bundle_bytes.len(), &ik_hex[..16], &iek_hex[..16], &spk_pub_hex[..16], &spk_secret_hex[..16], matches
|
||||
))
|
||||
}
|
||||
|
||||
/// Decrypt a bincode WireMessage. `spk_secret_hex` is the signed pre-key secret
|
||||
/// (stored in localStorage, generated during identity creation).
|
||||
/// Returns JSON: { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64...", "message_id": "..." }
|
||||
/// For Receipt messages: { "type": "receipt", "sender": "fp", "message_id": "...", "receipt_type": "delivered"|"read" }
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt_wire_message(
|
||||
identity_hex_seed: &str,
|
||||
spk_secret_hex: &str,
|
||||
message_bytes: &[u8],
|
||||
existing_session_b64: Option<String>,
|
||||
) -> Result<String, JsValue> {
|
||||
let seed_bytes = hex::decode(identity_hex_seed)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let mut sb = [0u8; 32];
|
||||
sb.copy_from_slice(&seed_bytes);
|
||||
let seed = Seed::from_bytes(sb);
|
||||
let id = seed.derive_identity();
|
||||
|
||||
let wire: WireMessage = warzone_protocol::message::deserialize_envelope(message_bytes)
|
||||
.map_err(|e| JsValue::from_str(&format!("deserialize wire: {}", e)))?;
|
||||
|
||||
match wire {
|
||||
WireMessage::KeyExchange {
|
||||
id: msg_id,
|
||||
sender_fingerprint,
|
||||
sender_identity_encryption_key,
|
||||
ephemeral_public,
|
||||
used_one_time_pre_key_id: _,
|
||||
ratchet_message,
|
||||
} => {
|
||||
// Use the STORED SPK secret, not a regenerated one
|
||||
let spk_bytes = hex::decode(spk_secret_hex)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let mut spk_arr = [0u8; 32];
|
||||
spk_arr.copy_from_slice(&spk_bytes);
|
||||
let spk_secret = x25519_dalek::StaticSecret::from(spk_arr);
|
||||
|
||||
let their_id = PublicKey::from(sender_identity_encryption_key);
|
||||
let their_eph = PublicKey::from(ephemeral_public);
|
||||
|
||||
let shared = x3dh::respond(&id, &spk_secret, None, &their_id, &their_eph)
|
||||
.map_err(|e| JsValue::from_str(&format!("X3DH respond: {}", e)))?;
|
||||
|
||||
let mut ratchet = RatchetState::init_bob(shared, spk_secret);
|
||||
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,
|
||||
&ratchet.serialize_versioned().unwrap_or_default(),
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sender": sender_fingerprint,
|
||||
"text": String::from_utf8_lossy(&plain),
|
||||
"new_session": true,
|
||||
"session_data": session_b64,
|
||||
"message_id": msg_id,
|
||||
}).to_string())
|
||||
}
|
||||
WireMessage::Message {
|
||||
id: msg_id,
|
||||
sender_fingerprint,
|
||||
ratchet_message,
|
||||
} => {
|
||||
let session_data = existing_session_b64
|
||||
.ok_or_else(|| JsValue::from_str("no session for this peer"))?;
|
||||
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::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,
|
||||
&ratchet.serialize_versioned().unwrap_or_default(),
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sender": sender_fingerprint,
|
||||
"text": String::from_utf8_lossy(&plain),
|
||||
"new_session": false,
|
||||
"session_data": session_b64,
|
||||
"message_id": msg_id,
|
||||
}).to_string())
|
||||
}
|
||||
WireMessage::Receipt {
|
||||
sender_fingerprint,
|
||||
message_id,
|
||||
receipt_type,
|
||||
} => {
|
||||
let rt_str = match receipt_type {
|
||||
ReceiptType::Delivered => "delivered",
|
||||
ReceiptType::Read => "read",
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"type": "receipt",
|
||||
"sender": sender_fingerprint,
|
||||
"message_id": message_id,
|
||||
"receipt_type": rt_str,
|
||||
}).to_string())
|
||||
}
|
||||
WireMessage::FileHeader {
|
||||
id, sender_fingerprint, filename, file_size, total_chunks, sha256,
|
||||
} => {
|
||||
Ok(serde_json::json!({
|
||||
"type": "file_header",
|
||||
"id": id,
|
||||
"sender": sender_fingerprint,
|
||||
"filename": filename,
|
||||
"file_size": file_size,
|
||||
"total_chunks": total_chunks,
|
||||
"sha256": sha256,
|
||||
}).to_string())
|
||||
}
|
||||
WireMessage::FileChunk {
|
||||
id, sender_fingerprint, filename, chunk_index, total_chunks, data,
|
||||
} => {
|
||||
Ok(serde_json::json!({
|
||||
"type": "file_chunk",
|
||||
"id": id,
|
||||
"sender": sender_fingerprint,
|
||||
"filename": filename,
|
||||
"chunk_index": chunk_index,
|
||||
"total_chunks": total_chunks,
|
||||
"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": "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");
|
||||
}
|
||||
}
|
||||
8
warzone/deploy/federation-kh3rad3ree.json
Normal file
8
warzone/deploy/federation-kh3rad3ree.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"server_id": "kh3rad3ree",
|
||||
"shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4",
|
||||
"peer": {
|
||||
"id": "mequ",
|
||||
"url": "http://10.66.66.129:7700"
|
||||
}
|
||||
}
|
||||
8
warzone/deploy/federation-mequ.json
Normal file
8
warzone/deploy/federation-mequ.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"server_id": "mequ",
|
||||
"shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4",
|
||||
"peer": {
|
||||
"id": "kh3rad3ree",
|
||||
"url": "http://10.66.66.253:7700"
|
||||
}
|
||||
}
|
||||
6
warzone/deploy/journald-warzone.conf
Normal file
6
warzone/deploy/journald-warzone.conf
Normal 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
60
warzone/deploy/setup.sh
Executable 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"
|
||||
2
warzone/deploy/warzone-server.env.kh3rad3ree
Normal file
2
warzone/deploy/warzone-server.env.kh3rad3ree
Normal file
@@ -0,0 +1,2 @@
|
||||
# kh3rad3ree: federation + bots enabled
|
||||
EXTRA_ARGS=--enable-bots
|
||||
2
warzone/deploy/warzone-server.env.mequ
Normal file
2
warzone/deploy/warzone-server.env.mequ
Normal file
@@ -0,0 +1,2 @@
|
||||
# mequ: federation only, no bots
|
||||
EXTRA_ARGS=
|
||||
28
warzone/deploy/warzone-server.service
Normal file
28
warzone/deploy/warzone-server.service
Normal 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
|
||||
689
warzone/docs/ARCHITECTURE.md
Normal file
689
warzone/docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,689 @@
|
||||
# Warzone Messenger (featherChat) — Architecture
|
||||
|
||||
**Version:** 0.0.21
|
||||
**Status:** Phase 1 + Phase 2 + WZP Integration + Federation
|
||||
|
||||
---
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
CLI[CLI Client] --> PROTO[warzone-protocol]
|
||||
TUI[TUI Client] --> PROTO
|
||||
WEB[Web Client WASM] --> PROTO
|
||||
BOT[Bots TG API] -->|HTTP| SRVA
|
||||
PROTO -->|HTTP / WS| SRVA[Server Alpha]
|
||||
PROTO -->|HTTP / WS| SRVB[Server Bravo]
|
||||
SRVA <-->|Federation WS| SRVB
|
||||
SRVA -->|Call Signaling| WZP[WarzonePhone Relay]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Crate Structure
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Workspace
|
||||
PROTO["warzone-protocol<br/>(library, no I/O)"]
|
||||
SERVER["warzone-server<br/>(axum binary)"]
|
||||
CLIENT["warzone-client<br/>(CLI/TUI binary)"]
|
||||
WASM["warzone-wasm<br/>(wasm-bindgen)"]
|
||||
MULE["warzone-mule<br/>(future)"]
|
||||
end
|
||||
|
||||
SERVER --> PROTO
|
||||
CLIENT --> PROTO
|
||||
WASM --> PROTO
|
||||
MULE --> PROTO
|
||||
|
||||
subgraph External["WarzonePhone (submodule)"]
|
||||
WZP_PROTO["wzp-proto"]
|
||||
WZP_CRYPTO["wzp-crypto"]
|
||||
WZP_RELAY["wzp-relay"]
|
||||
WZP_WEB["wzp-web"]
|
||||
end
|
||||
```
|
||||
|
||||
```
|
||||
warzone/
|
||||
├── Cargo.toml # Workspace root (v0.0.21)
|
||||
├── federation.example.json # Federation config template
|
||||
├── crates/
|
||||
│ ├── warzone-protocol/ # Core crypto & message types
|
||||
│ ├── warzone-server/ # Server binary (axum + sled)
|
||||
│ ├── warzone-client/ # CLI/TUI client binary
|
||||
│ ├── warzone-wasm/ # WASM bridge for web client
|
||||
│ └── warzone-mule/ # Mule binary (future)
|
||||
├── warzone-phone/ # WZP submodule (voice/video)
|
||||
└── docs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protocol Modules
|
||||
|
||||
### warzone-protocol
|
||||
|
||||
| Module | Purpose |
|
||||
|---------------|------------------------------------------------------|
|
||||
| `identity` | Seed, IdentityKeyPair, PublicIdentity, Fingerprint |
|
||||
| `mnemonic` | BIP39 mnemonic encode/decode (24 words) |
|
||||
| `crypto` | HKDF-SHA256, ChaCha20-Poly1305 AEAD |
|
||||
| `prekey` | SignedPreKey, OneTimePreKey, PreKeyBundle |
|
||||
| `x3dh` | X3DH key agreement (initiate + respond) |
|
||||
| `ratchet` | Double Ratchet state machine (MAX_SKIP=1000) |
|
||||
| `message` | WireMessage enum (8 variants), CallSignalType |
|
||||
| `sender_keys` | Sender Key protocol for group encryption |
|
||||
| `history` | Encrypted backup/restore |
|
||||
| `ethereum` | secp256k1, Keccak-256, Ethereum address derivation |
|
||||
| `friends` | E2E encrypted friend list (encrypt/decrypt with HKDF key) |
|
||||
| `types` | Fingerprint, DeviceId, SessionId, MessageId |
|
||||
|
||||
### warzone-server
|
||||
|
||||
| Module | Purpose |
|
||||
|----------------------|---------------------------------------------------|
|
||||
| `main` | CLI args, startup, federation init |
|
||||
| `state` | AppState, Connections, CallState, DedupTracker |
|
||||
| `db` | 9 sled trees: keys, messages, groups, aliases, tokens, calls, missed_calls, friends, eth_addresses |
|
||||
| `federation` | Peer config, presence sync, message forwarding |
|
||||
| `auth_middleware` | Bearer token extractor (401 on protected routes) |
|
||||
| `routes/auth` | Challenge-response authentication |
|
||||
| `routes/ws` | WebSocket relay + call signaling awareness |
|
||||
| `routes/messages` | Send, poll (fetch-and-delete), ack |
|
||||
| `routes/groups` | Create, join, leave, kick, members, send |
|
||||
| `routes/calls` | Call CRUD, group call initiation |
|
||||
| `routes/devices` | Device listing, kick, revoke-all |
|
||||
| `routes/presence` | Online status (single + batch) |
|
||||
| `routes/federation` | Peer presence sync + message forwarding |
|
||||
| `routes/wzp` | WZP relay config + service token |
|
||||
| `routes/aliases` | Alias CRUD with TTL + recovery keys |
|
||||
| `routes/keys` | Pre-key bundle registration & retrieval |
|
||||
| `routes/friends` | Encrypted friend list blob storage (GET/POST) |
|
||||
| `routes/bot` | Telegram Bot API compatibility layer |
|
||||
| `routes/resolve` | Address resolution (ETH/alias/fingerprint → fp) |
|
||||
|
||||
### warzone-client (TUI)
|
||||
|
||||
| Module | Purpose |
|
||||
|--------------------|-------------------------------------------------|
|
||||
| `tui/mod` | Event loop, run_tui() entry point |
|
||||
| `tui/types` | App, ChatLine, scroll/connection state |
|
||||
| `tui/draw` | Rendering: timestamps, scroll, status dot, badge |
|
||||
| `tui/input` | Keyboard: text editing, scroll keys |
|
||||
| `tui/commands` | /help, /call, /devices, /kick, 20+ commands |
|
||||
| `tui/file_transfer`| Chunked file send (DM + group) |
|
||||
| `tui/network` | WS/HTTP polling, group decrypt, session recovery |
|
||||
| `storage` | LocalDb: sessions, pre_keys, contacts, history, sender_keys |
|
||||
|
||||
### warzone-wasm
|
||||
|
||||
| Export | Purpose |
|
||||
|-----------------------------------|--------------------------------------------|
|
||||
| `WasmIdentity` | Seed generation, fingerprint, bundle |
|
||||
| `WasmSession` | Encrypt/decrypt with Double Ratchet |
|
||||
| `decrypt_wire_message` | Full message pipeline (all 8 variants) |
|
||||
| `create_receipt` | Build receipt WireMessages |
|
||||
| `decrypt_group_message` | Sender Key group decryption |
|
||||
| `create_sender_key_from_distribution` | Build SenderKey from distribution |
|
||||
| `self_test` | End-to-end crypto verification in WASM |
|
||||
|
||||
---
|
||||
|
||||
## Cryptographic Stack
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
PLAIN["Plaintext Message"] --> DR["Double Ratchet<br/>(per-message keys)"]
|
||||
DR --> X3DH_INIT["X3DH Session Init<br/>(3-4 DH operations)"]
|
||||
X3DH_INIT --> AEAD["ChaCha20-Poly1305<br/>(AEAD encryption)"]
|
||||
AEAD --> SIGN["Ed25519 Signature<br/>(pre-key signing)"]
|
||||
SIGN --> WIRE["WireMessage<br/>(bincode serialization)"]
|
||||
WIRE --> TRANSPORT["HTTP POST / WS Binary"]
|
||||
|
||||
style DR fill:#2d5016,color:#fff
|
||||
style AEAD fill:#1a3a5c,color:#fff
|
||||
style X3DH_INIT fill:#4a1a5c,color:#fff
|
||||
```
|
||||
|
||||
### Primitives
|
||||
|
||||
| Primitive | Crate | Purpose |
|
||||
|-----------------------|----------------------|------------------------------------------|
|
||||
| Ed25519 | `ed25519-dalek` | Signing, identity verification |
|
||||
| X25519 | `x25519-dalek` | Diffie-Hellman key exchange |
|
||||
| ChaCha20-Poly1305 | `chacha20poly1305` | Authenticated encryption (AEAD) |
|
||||
| HKDF-SHA256 | `hkdf` + `sha2` | Key derivation with domain separation |
|
||||
| SHA-256 | `sha2` | Fingerprints, file integrity, room hashing |
|
||||
| Argon2id | `argon2` | Passphrase-based seed encryption at rest |
|
||||
| secp256k1 ECDSA | `k256` | Ethereum-compatible signing |
|
||||
| Keccak-256 | `tiny-keccak` | Ethereum address derivation |
|
||||
|
||||
---
|
||||
|
||||
## Identity Derivation
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
SEED["BIP39 Seed<br/>(32 bytes, 24 words)"]
|
||||
SEED -->|"HKDF(info='warzone-ed25519')"| ED["Ed25519 Signing Key"]
|
||||
SEED -->|"HKDF(info='warzone-x25519')"| X25519["X25519 Encryption Key"]
|
||||
SEED -->|"HKDF(info='warzone-secp256k1')"| SECP["secp256k1 Key"]
|
||||
SEED -->|"HKDF(info='warzone-history')"| HIST["History Encryption Key"]
|
||||
|
||||
ED -->|"SHA-256[:16]"| FP["Fingerprint<br/>xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx"]
|
||||
SECP -->|"Keccak-256[-20:]"| ETH["Ethereum Address<br/>0x..."]
|
||||
```
|
||||
|
||||
A single mnemonic controls: messaging identity (Ed25519 + X25519), Ethereum wallet (secp256k1), and backup encryption. WarzonePhone uses the same seed with identical HKDF parameters for shared identity (verified by 15 cross-project tests).
|
||||
|
||||
---
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
### WireMessage Variants
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
WM["WireMessage (bincode)"]
|
||||
WM --> KE["KeyExchange<br/>X3DH + first ratchet msg"]
|
||||
WM --> MSG["Message<br/>Double Ratchet encrypted"]
|
||||
WM --> REC["Receipt<br/>Sent/Delivered/Read"]
|
||||
WM --> FH["FileHeader<br/>filename, size, SHA-256"]
|
||||
WM --> FC["FileChunk<br/>64KB encrypted chunks"]
|
||||
WM --> GSK["GroupSenderKey<br/>Sender Key encrypted"]
|
||||
WM --> SKD["SenderKeyDistribution<br/>Share key via 1:1 channel"]
|
||||
WM --> CS["CallSignal<br/>Offer/Answer/Hangup/..."]
|
||||
```
|
||||
|
||||
### CallSignalType
|
||||
|
||||
```
|
||||
Offer | Answer | IceCandidate | Hangup | Reject | Ringing | Busy
|
||||
```
|
||||
|
||||
### Transport Encoding
|
||||
|
||||
| Client | Path | Format |
|
||||
|-----------|---------------|--------|
|
||||
| CLI/TUI | WS binary | 64 hex chars (recipient fp) + raw bincode |
|
||||
| CLI/TUI | HTTP POST | JSON envelope with bincode as byte array |
|
||||
| Web | WS JSON | `{"to": "fingerprint", "message": [bytes]}` |
|
||||
| Server↔Server | WS JSON | JSON frames over persistent federation WS |
|
||||
|
||||
---
|
||||
|
||||
## Server Architecture
|
||||
|
||||
### Route Map
|
||||
|
||||
```
|
||||
Auth-Protected (bearer token required):
|
||||
POST /v1/messages/send Send encrypted message
|
||||
POST /v1/groups/create|join|send|leave|kick
|
||||
POST /v1/alias/register|unregister|recover|renew|admin-remove
|
||||
POST /v1/keys/register|replenish
|
||||
POST /v1/calls/initiate|:id/end
|
||||
POST /v1/groups/:name/call Group call initiation
|
||||
POST /v1/devices/:id/kick Kick a device
|
||||
POST /v1/devices/revoke-all Panic button
|
||||
POST /v1/presence/batch Bulk online check
|
||||
|
||||
Public (no auth):
|
||||
GET /v1/keys/:fp Fetch pre-key bundle
|
||||
GET /v1/messages/poll/:fp Fetch queued messages
|
||||
GET /v1/groups/:name|list|members
|
||||
GET /v1/alias/resolve/:name|list|whois/:fp
|
||||
GET /v1/calls/:id|active|missed
|
||||
GET /v1/presence/:fp Online status
|
||||
GET /v1/devices List own devices (auth)
|
||||
GET /v1/wzp/relay-config WZP relay address + token
|
||||
GET /v1/federation/status Federation health
|
||||
GET /v1/ws/:fp WebSocket upgrade
|
||||
GET /v1/friends Encrypted friend list (auth)
|
||||
POST /v1/friends Save friend list (auth)
|
||||
GET /v1/resolve/:address ETH/alias/fp resolution
|
||||
POST /v1/bot/register Register a bot
|
||||
GET /v1/bot/:token/getMe Bot identity
|
||||
POST /v1/bot/:token/getUpdates Long-poll for messages
|
||||
POST /v1/bot/:token/sendMessage Send message as bot
|
||||
POST /v1/auth/challenge|verify|validate
|
||||
|
||||
Federation (HMAC-authenticated, server-to-server):
|
||||
POST /v1/federation/presence Presence sync
|
||||
POST /v1/federation/forward Message forwarding
|
||||
```
|
||||
|
||||
### Message Routing
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
MSG["Incoming Message<br/>for fingerprint X"] --> DEDUP{"Dedup Check<br/>(10K FIFO)"}
|
||||
DEDUP -->|Duplicate| DROP["Drop"]
|
||||
DEDUP -->|New| LOCAL{"push_to_client(X)<br/>Local WS?"}
|
||||
LOCAL -->|Delivered| DONE["Done"]
|
||||
LOCAL -->|Not local| FED{"Federation<br/>enabled?"}
|
||||
FED -->|No| QUEUE["Queue in<br/>sled DB"]
|
||||
FED -->|Yes| REMOTE{"X in remote<br/>presence?"}
|
||||
REMOTE -->|No| QUEUE
|
||||
REMOTE -->|Yes| FORWARD["HTTP POST to peer<br/>/v1/federation/forward"]
|
||||
FORWARD -->|Success| DONE
|
||||
FORWARD -->|Peer down| QUEUE
|
||||
|
||||
style DONE fill:#2d5016,color:#fff
|
||||
style DROP fill:#5c1a1a,color:#fff
|
||||
style QUEUE fill:#4a3a1a,color:#fff
|
||||
```
|
||||
|
||||
### WebSocket Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Server
|
||||
|
||||
C->>S: GET /v1/ws/:fingerprint
|
||||
S->>S: Check connection cap (max 5)
|
||||
S->>C: WS Upgrade
|
||||
|
||||
Note over S: Flush queued messages
|
||||
S->>C: Binary(queued_msg_1)
|
||||
S->>C: Binary(queued_msg_2)
|
||||
|
||||
Note over S: Flush missed calls
|
||||
S->>C: Text({"type":"missed_call",...})
|
||||
|
||||
Note over S: Register push channel
|
||||
|
||||
loop Real-time
|
||||
C->>S: Binary(64-hex-fp + bincode)
|
||||
S->>S: Dedup + Call signal awareness
|
||||
S->>S: deliver_or_queue(recipient)
|
||||
end
|
||||
|
||||
C->>S: Close
|
||||
S->>S: Cleanup stale senders
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Federation
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Alpha[Server Alpha]
|
||||
CA[Client A + B]
|
||||
end
|
||||
subgraph Bravo[Server Bravo]
|
||||
CC[Client C + D]
|
||||
end
|
||||
Alpha <-->|Persistent WS\nPresence + Forward| Bravo
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Each server has a `federation.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_id": "alpha",
|
||||
"shared_secret": "long-random-string-shared-between-both",
|
||||
"peer": {
|
||||
"id": "bravo",
|
||||
"url": "http://10.0.0.2:7700"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Start with: `warzone-server --federation federation.json`
|
||||
|
||||
### Presence Sync
|
||||
|
||||
On startup each server opens a persistent WebSocket to its peer and authenticates with the shared secret. Presence updates and message forwards flow over this single connection:
|
||||
|
||||
```
|
||||
WS /v1/federation/ws
|
||||
Auth: {"type":"auth","secret":"HMAC(shared_secret)"}
|
||||
Presence: {"type":"presence","fingerprints":["aabb...","ccdd..."]}
|
||||
Forward: {"type":"forward","to":"<fp>","message":"<base64>"}
|
||||
```
|
||||
|
||||
The receiving server replaces its remote presence set on each presence frame. If the WebSocket drops, the server auto-reconnects every 3 seconds and re-sends its full presence list.
|
||||
|
||||
### Message Forwarding
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SA as Server Alpha
|
||||
participant SB as Server Bravo
|
||||
|
||||
Note over SA,SB: Persistent WS connection
|
||||
SA->>SB: {"type":"auth","secret":"..."}
|
||||
SA->>SB: {"type":"presence","fingerprints":["A","B"]}
|
||||
SB->>SA: {"type":"presence","fingerprints":["C","D"]}
|
||||
|
||||
Note over SA: Client A sends message to C
|
||||
SA->>SB: {"type":"forward","to":"C","message":"base64..."}
|
||||
Note over SB: Deliver to Client C via local WS
|
||||
```
|
||||
|
||||
### Degradation
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| WS disconnected | Auto-reconnect every 3s, messages queue locally |
|
||||
| Peer restarts | Presence repopulates on WS reconnect |
|
||||
| HMAC mismatch | Request rejected with 401 |
|
||||
|
||||
### Federated Features
|
||||
|
||||
| Feature | How it works |
|
||||
|---------|-------------|
|
||||
| Message forwarding | deliver_or_queue() checks remote presence, forwards via WS |
|
||||
| Key lookup | get_bundle() proxies to peer if fingerprint is not local |
|
||||
| Alias resolution | resolve_alias() falls back to peer server |
|
||||
| ETH resolution | resolve endpoint checks peer via HTTP |
|
||||
| Presence | Bidirectional sync every 10s + on-connect |
|
||||
|
||||
---
|
||||
|
||||
## Call Infrastructure (WZP Integration)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller as Caller (TUI)
|
||||
participant FC as featherChat Server
|
||||
participant WZP as WZP Relay
|
||||
|
||||
Caller->>FC: WireMessage::CallSignal(Offer)
|
||||
FC->>FC: Create CallState(Ringing)
|
||||
FC->>FC: push_to_client(callee)
|
||||
|
||||
alt Callee online
|
||||
FC-->>Callee: CallSignal(Offer) via WS
|
||||
Callee->>FC: CallSignal(Answer)
|
||||
FC->>FC: Update CallState(Active)
|
||||
Note over Caller,WZP: Both connect to WZP Relay with bearer token
|
||||
Caller->>WZP: QUIC + AuthToken + Handshake
|
||||
Callee->>WZP: QUIC + AuthToken + Handshake
|
||||
Note over WZP: Encrypted media flows (ChaCha20-Poly1305)
|
||||
else Callee offline
|
||||
FC->>FC: Record missed call in sled
|
||||
Note over FC: Flushed on callee's next WS connect
|
||||
end
|
||||
|
||||
Caller->>FC: CallSignal(Hangup)
|
||||
FC->>FC: Update CallState(Ended)
|
||||
```
|
||||
|
||||
### Server Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `POST /v1/calls/initiate` | Create call (returns call_id) |
|
||||
| `GET /v1/calls/:id` | Get call state |
|
||||
| `POST /v1/calls/:id/end` | End a call |
|
||||
| `GET /v1/calls/active` | List active calls |
|
||||
| `POST /v1/calls/missed` | Get & clear missed calls |
|
||||
| `POST /v1/groups/:name/call` | Group call (fan-out to members) |
|
||||
| `GET /v1/presence/:fp` | Check if peer is online |
|
||||
| `GET /v1/wzp/relay-config` | Get relay address + service token |
|
||||
|
||||
### Group Call Room ID
|
||||
|
||||
```
|
||||
room_id = hex(SHA-256("featherchat-group:" + group_name)[:16])
|
||||
```
|
||||
|
||||
Deterministic, 32 hex chars. Prevents leaking group name to relay via QUIC SNI.
|
||||
|
||||
---
|
||||
|
||||
## Device Management
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
USER["User with<br/>3 devices"] --> LIST["GET /v1/devices<br/>(lists all sessions)"]
|
||||
USER --> KICK["POST /v1/devices/:id/kick<br/>(force-close one)"]
|
||||
USER --> REVOKE["POST /v1/devices/revoke-all<br/>(nuke all except current)"]
|
||||
|
||||
KICK --> CLOSE["WS channel closed<br/>+ token invalidated"]
|
||||
REVOKE --> NUKE["All WS closed<br/>+ all tokens cleared"]
|
||||
```
|
||||
|
||||
- Max 5 WS connections per fingerprint
|
||||
- Stale connections auto-cleaned on new registrations
|
||||
- `/devices` and `/kick <id>` available as TUI commands
|
||||
|
||||
---
|
||||
|
||||
## Bot API (Telegram-Compatible)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Dev as Bot Developer
|
||||
participant S as featherChat Server
|
||||
participant U as User
|
||||
|
||||
Dev->>S: POST /v1/bot/register {name, fp}
|
||||
S->>Dev: {token, alias: "@mybot_bot"}
|
||||
|
||||
loop Long-poll
|
||||
Dev->>S: POST /bot/:token/getUpdates
|
||||
S->>Dev: [updates...]
|
||||
end
|
||||
|
||||
U->>S: Message to @mybot_bot
|
||||
S->>S: Queue for bot fp
|
||||
Dev->>S: getUpdates → receives message
|
||||
Dev->>S: POST /bot/:token/sendMessage
|
||||
S->>U: Deliver reply via WS
|
||||
```
|
||||
|
||||
- Bots register with a fingerprint and get a token
|
||||
- Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced)
|
||||
- Non-bot users cannot register reserved aliases
|
||||
- `getUpdates` returns Telegram-compatible Update objects
|
||||
- `sendMessage` delivers plaintext (no E2E in v1)
|
||||
- Messages from users arrive as encrypted blobs (base64) or plaintext bot messages
|
||||
|
||||
### Addressing
|
||||
|
||||
Three address formats, all interchangeable:
|
||||
|
||||
| Format | Example | Usage |
|
||||
|--------|---------|-------|
|
||||
| Fingerprint | `522d:4d6e:a8ee:588a:...` | Internal routing, crypto |
|
||||
| ETH address | `0x742d35Cc6634C0532...` | User-facing display |
|
||||
| Alias | `@alice`, `@weatherbot` | Human-friendly |
|
||||
|
||||
Resolution: `GET /v1/resolve/:address` accepts any format, returns fingerprint.
|
||||
ETH↔fingerprint mapping stored on key registration.
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
### What's Protected
|
||||
|
||||
| Layer | Protection | Mechanism |
|
||||
|-------|-----------|-----------|
|
||||
| Message content | E2E encrypted | ChaCha20-Poly1305 via Double Ratchet |
|
||||
| Forward secrecy | Per-message keys | DH ratchet step on direction change |
|
||||
| Session establishment | Authenticated | X3DH with signed pre-keys |
|
||||
| Identity | Deterministic from seed | HKDF with domain separation |
|
||||
| Seed at rest | Encrypted | Argon2id passphrase KDF |
|
||||
| API writes | Auth-gated | Bearer token middleware (401) |
|
||||
| Inter-server | Authenticated | SHA-256(secret \|\| body) token |
|
||||
| WS connections | Rate-limited | 5 per fingerprint, 200 global |
|
||||
| WZP relay | Token-gated | featherChat bearer token validation |
|
||||
|
||||
### What's NOT Protected (Phase 1 scope)
|
||||
|
||||
| Data | Exposure |
|
||||
|------|----------|
|
||||
| Sender/recipient metadata | Server sees routing info |
|
||||
| Message timing | Server sees timestamps |
|
||||
| Online/offline status | Server tracks WS connections |
|
||||
| Group membership | Server stores plaintext member list |
|
||||
| IP addresses | Server logs (standard for HTTP) |
|
||||
|
||||
Planned mitigations: sealed sender (Phase 6), onion routing, metadata encryption.
|
||||
|
||||
### Trust Boundaries
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph TRUSTED["Trusted: Your Device"]
|
||||
SEED["Seed in memory"]
|
||||
LDB["Local sled DB"]
|
||||
end
|
||||
|
||||
subgraph SEMI["Semi-Trusted: Server"]
|
||||
SRVR["Sees metadata<br/>Can't read messages"]
|
||||
end
|
||||
|
||||
subgraph UNTRUSTED["Untrusted: Network"]
|
||||
NET["TLS protects transport"]
|
||||
end
|
||||
|
||||
TRUSTED -->|"E2E encrypted + TLS"| SEMI
|
||||
SEMI -->|"TLS"| UNTRUSTED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Model
|
||||
|
||||
### Server sled Trees (9)
|
||||
|
||||
| Tree | Key Format | Value |
|
||||
|----------------|---------------------------|--------------------------|
|
||||
| `keys` | `<fingerprint>` | bincode PreKeyBundle |
|
||||
| `messages` | `queue:<fp>:<uuid>` | raw bincode WireMessage |
|
||||
| `groups` | `<group_name>` | JSON GroupInfo |
|
||||
| `aliases` | `a:<alias>`, `fp:<fp>`, `rec:<alias>` | Various |
|
||||
| `tokens` | `<token_hex>` | JSON {fp, expires_at} |
|
||||
| `calls` | `<call_id>` | JSON CallState |
|
||||
| `missed_calls` | `missed:<fp>:<call_id>` | JSON {caller, timestamp} |
|
||||
| `friends` | `<fingerprint>` | Encrypted blob (ChaCha20) |
|
||||
| `eth_addresses` | `0x...` or `rev:<fp>` | ETH↔fingerprint mapping |
|
||||
|
||||
### Client sled Trees (5)
|
||||
|
||||
| Tree | Key Format | Value |
|
||||
|----------------|---------------------------|--------------------------|
|
||||
| `sessions` | `<peer_fp_hex>` | bincode RatchetState |
|
||||
| `pre_keys` | `spk:<id>`, `otpk:<id>` | 32-byte StaticSecret |
|
||||
| `contacts` | `<fingerprint>` | JSON contact record |
|
||||
| `history` | `hist:<fp>:<ts>:<uuid>` | JSON message record |
|
||||
| `sender_keys` | `sk:<fp>:<group>` | bincode SenderKey |
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Crate | Tests | Coverage |
|
||||
|-------|------:|---------|
|
||||
| warzone-protocol | 34 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client |
|
||||
| warzone-client (types) | 10 | App init, scroll, connected, timestamps, normfp |
|
||||
| warzone-client (input) | 25 | Text editing, cursor movement, scroll keys, quit |
|
||||
| warzone-client (draw) | 9 | Rendering, timestamps, connection dot, scroll, unread badge |
|
||||
| **Total** | **122** | All passing |
|
||||
|
||||
WZP side: 15 cross-project identity tests + 17 integration tests (separate repo).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Diagrams
|
||||
|
||||
### 1:1 Direct Message (First Contact)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Alice
|
||||
participant S as Server
|
||||
participant B as Bob
|
||||
|
||||
A->>S: GET /v1/keys/:bob_fp
|
||||
S->>A: PreKeyBundle (bincode)
|
||||
|
||||
Note over A: X3DH initiate(bundle)<br/>Double Ratchet init_alice()<br/>ratchet.encrypt("hello")
|
||||
|
||||
A->>S: WireMessage::KeyExchange
|
||||
S->>B: Push via WS (or queue)
|
||||
|
||||
Note over B: X3DH respond(spk_secret)<br/>init_bob()<br/>ratchet.decrypt() = "hello"
|
||||
|
||||
B->>S: WireMessage::Receipt(Delivered)
|
||||
S->>A: Push receipt
|
||||
```
|
||||
|
||||
### Group Message (Sender Keys)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Alice
|
||||
participant S as Server
|
||||
participant B as Bob
|
||||
participant C as Carol
|
||||
|
||||
Note over A: SenderKey::generate("ops")
|
||||
|
||||
A->>S: SenderKeyDistribution (via 1:1 to Bob)
|
||||
S->>B: Push distribution
|
||||
A->>S: SenderKeyDistribution (via 1:1 to Carol)
|
||||
S->>C: Push distribution
|
||||
|
||||
Note over A: sender_key.encrypt("attack")
|
||||
|
||||
A->>S: POST /groups/ops/send (GroupSenderKey)
|
||||
S->>B: Fan-out
|
||||
S->>C: Fan-out
|
||||
|
||||
Note over B,C: sender_key.decrypt() = "attack"
|
||||
```
|
||||
|
||||
### Federated Message
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Client A (Alpha)
|
||||
participant SA as Server Alpha
|
||||
participant SB as Server Bravo
|
||||
participant C as Client C (Bravo)
|
||||
|
||||
Note over SA,SB: Persistent WS between servers
|
||||
SA->>SB: presence ["A","B"]
|
||||
SB->>SA: presence ["C","D"]
|
||||
|
||||
A->>SA: Message for C
|
||||
SA->>SA: Not local, C in remote presence
|
||||
SA->>SB: forward to C via federation WS
|
||||
SB->>C: Push via local WS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding New WireMessage Variants
|
||||
|
||||
1. Add variant to `WireMessage` in `warzone-protocol/src/message.rs`
|
||||
2. Update `extract_message_id()` in `routes/messages.rs` and `routes/ws.rs`
|
||||
3. Handle in `tui/network.rs` (process_wire_message)
|
||||
4. Handle in `warzone-wasm/src/lib.rs` (decrypt_wire_message)
|
||||
5. bincode serialization is automatic
|
||||
|
||||
### Adding New Server Routes
|
||||
|
||||
1. Create module in `routes/`
|
||||
2. Implement `pub fn routes() -> Router<AppState>`
|
||||
3. Merge in `routes/mod.rs`
|
||||
4. Add `_auth: AuthFingerprint` for write endpoints
|
||||
|
||||
### Adding Federation Peers (Future)
|
||||
|
||||
Current: 1 peer via JSON config. Future: N peers via config array or DNS discovery. The `deliver_or_queue()` method would iterate over peers checking remote presence.
|
||||
440
warzone/docs/BOT_API.md
Normal file
440
warzone/docs/BOT_API.md
Normal 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.
|
||||
596
warzone/docs/CLIENT.md
Normal file
596
warzone/docs/CLIENT.md
Normal file
@@ -0,0 +1,596 @@
|
||||
# Warzone Client -- Operation Guide
|
||||
|
||||
**Version:** 0.0.21
|
||||
|
||||
---
|
||||
|
||||
## 1. Installation
|
||||
|
||||
### Build from Source
|
||||
|
||||
Requires Rust 1.75+.
|
||||
|
||||
```bash
|
||||
cd warzone/
|
||||
cargo build -p warzone-client --release
|
||||
```
|
||||
|
||||
The binary is at `target/release/warzone`. You can copy it anywhere or add
|
||||
`target/release` to your `PATH`.
|
||||
|
||||
```bash
|
||||
# Optional: install to ~/.cargo/bin
|
||||
cargo install --path crates/warzone-client
|
||||
```
|
||||
|
||||
### Build the WASM Module (Web Client)
|
||||
|
||||
Requires wasm-pack.
|
||||
|
||||
```bash
|
||||
cd crates/warzone-wasm
|
||||
wasm-pack build --target web
|
||||
# Output in pkg/ — copy to web client directory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. TUI Architecture
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
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.
|
||||
```
|
||||
|
||||
**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. 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...>`
|
||||
|
||||
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
|
||||
Set passphrase (empty for no encryption): ****
|
||||
Confirm passphrase: ****
|
||||
Identity recovered. Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
```
|
||||
|
||||
Recovery restores the seed and keypair. Pre-keys and sessions are NOT restored;
|
||||
contacts will need to re-establish sessions.
|
||||
|
||||
---
|
||||
|
||||
### `warzone info`
|
||||
|
||||
Display your fingerprint and public keys.
|
||||
|
||||
```bash
|
||||
$ warzone info
|
||||
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 tui` / `warzone chat [peer]`
|
||||
|
||||
Launch the interactive TUI client.
|
||||
|
||||
```bash
|
||||
$ 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 |
|
||||
|
||||
---
|
||||
|
||||
### `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://wz.example.com:7700
|
||||
$ warzone send @alice "Hello!" --server http://wz.example.com:7700
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
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: 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`
|
||||
|
||||
Poll for and decrypt incoming messages.
|
||||
|
||||
```bash
|
||||
$ warzone recv --server http://wz.example.com:7700
|
||||
```
|
||||
|
||||
Fetches messages from `/v1/messages/poll/{fingerprint}`, deserializes each
|
||||
`WireMessage`, performs X3DH respond or ratchet decrypt as appropriate, and
|
||||
prints plaintext to stdout.
|
||||
|
||||
---
|
||||
|
||||
### `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 `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", ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `warzone restore <input>`
|
||||
|
||||
Restore from an encrypted backup. Requires the same seed (passphrase prompt).
|
||||
|
||||
```bash
|
||||
$ warzone restore my-backup.wzb
|
||||
Restored 12 entries from my-backup.wzb
|
||||
```
|
||||
|
||||
Merges data without overwriting existing entries.
|
||||
|
||||
---
|
||||
|
||||
## 4. TUI Features
|
||||
|
||||
### Message Timestamps
|
||||
|
||||
Every message is rendered with a `[HH:MM]` prefix in dark gray, derived from
|
||||
`chrono::Local::now()` at receive/send time.
|
||||
|
||||
### Message Scrolling
|
||||
|
||||
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. 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.
|
||||
|
||||
The server stores the blob at `POST /v1/friends` and returns it at
|
||||
`GET /v1/friends`. It has no knowledge of the contents.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
A "session" is a Double Ratchet state between you and one peer, identified
|
||||
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 hex fingerprint.
|
||||
|
||||
2. **Subsequent messages:** the ratchet state is loaded, used to encrypt or
|
||||
decrypt, then saved back.
|
||||
|
||||
3. **Bidirectional:** when Bob receives Alice's `KeyExchange`, he initializes
|
||||
his side. From then on, both use `WireMessage::Message`.
|
||||
|
||||
### Session Auto-Recovery
|
||||
|
||||
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.
|
||||
|
||||
### Multi-Device
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 11. Troubleshooting
|
||||
|
||||
### "No identity found. Run `warzone init` first."
|
||||
|
||||
`~/.warzone/identity.seed` is missing. Run `warzone init`.
|
||||
|
||||
### "No bundle found. Run `warzone init` first."
|
||||
|
||||
`~/.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 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"
|
||||
|
||||
Signed pre-key secret missing from local database. Database may have been
|
||||
deleted or corrupted. Re-initialize with `warzone init`.
|
||||
|
||||
### "[session reset] Decryption failed"
|
||||
|
||||
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
|
||||
|
||||
```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>
|
||||
```
|
||||
676
warzone/docs/FUTURE_TASKS.md
Normal file
676
warzone/docs/FUTURE_TASKS.md
Normal file
@@ -0,0 +1,676 @@
|
||||
# Future Tasks & Improvement Suggestions
|
||||
|
||||
These are optional improvements identified during development. Each includes context, effort estimate, and questions to answer before starting.
|
||||
|
||||
---
|
||||
|
||||
## Priority: High (Security/Reliability)
|
||||
|
||||
### 1. Auth Enforcement Middleware
|
||||
**What:** Add axum middleware to enforce bearer tokens on protected endpoints.
|
||||
**Why:** Currently anyone can impersonate any fingerprint — the auth system issues tokens but doesn't require them.
|
||||
**Effort:** Half a day (~200 lines)
|
||||
**Blocked by:** Nothing — can be done now.
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should we enforce auth on all `/v1/*` routes or only write operations (send, groups, aliases)?
|
||||
- [ ] Should the web client use cookie-based auth (simpler) or bearer tokens (consistent with CLI)?
|
||||
- [ ] What's the token refresh strategy — silent refresh or re-auth on expiry?
|
||||
|
||||
---
|
||||
|
||||
### 2. Session Auto-Recovery
|
||||
**What:** When ratchet decryption fails (corrupted state), auto-send a new X3DH KeyExchange to re-establish the session.
|
||||
**Why:** Currently a corrupted session = permanent inability to decrypt from that peer until manual intervention.
|
||||
**Effort:** 1 day
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should we show a warning ("security session was reset") like Signal does?
|
||||
- [ ] Should we keep the old corrupted session state for debugging, or just delete it?
|
||||
- [ ] How many auto-recovery attempts before giving up (prevent infinite loops)?
|
||||
|
||||
---
|
||||
|
||||
### 3. Crypto Audit Plan
|
||||
**What:** Prepare the codebase for a professional cryptographic audit.
|
||||
**Why:** We implemented X3DH + Double Ratchet from scratch. Production use requires independent verification.
|
||||
**Effort:** 1 week (documentation + code cleanup), then $20-50K for audit firm
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Do we want to audit now (before federation) or after Phase 3?
|
||||
- [ ] Budget range? Trail of Bits (~$50K), Cure53 (~$30K), NCC Group (~$40K)?
|
||||
- [ ] Should we migrate to libsignal instead? (Avoids audit but loses WASM + static binary)
|
||||
|
||||
---
|
||||
|
||||
## Priority: Medium (Architecture/Quality)
|
||||
|
||||
### 4. Extract Web Client from Monolith
|
||||
**What:** Split the ~1000-line JS embedded in `web.rs` into separate files (app.js, crypto.js, ws.js, ui.js, style.css).
|
||||
**Why:** Currently unmaintainable — one raw string in Rust containing all HTML/CSS/JS.
|
||||
**Effort:** 1-2 days, zero functionality change
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Serve as static files from disk, or embed multiple files at compile time?
|
||||
- [ ] Use ES modules (modern) or keep everything global (simpler)?
|
||||
- [ ] Add a minimal build step (esbuild for bundling) or keep it build-free?
|
||||
|
||||
---
|
||||
|
||||
### 5. Session State Versioning
|
||||
**What:** Add version byte to serialized ratchet/session state. Migrate old formats instead of failing.
|
||||
**Why:** Any change to `RatchetState` struct breaks all existing sessions silently.
|
||||
**Effort:** Half a day
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Just a version prefix byte, or a full envelope format (version + length + data)?
|
||||
- [ ] Should we support migrating from v1→v2, or just re-establish sessions on version mismatch?
|
||||
|
||||
---
|
||||
|
||||
### 6. Periodic Auto-Backup
|
||||
**What:** Automatically backup session state + contacts + history every N minutes to an encrypted local file.
|
||||
**Why:** Currently backup is manual (`warzone backup`). If the process crashes, unsaved sessions are lost.
|
||||
**Effort:** Half a day
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Backup interval? Every 5 minutes? Every new session? On quit?
|
||||
- [ ] Where to store? Same directory as DB? Configurable?
|
||||
- [ ] Keep N backups with rotation, or just latest?
|
||||
|
||||
---
|
||||
|
||||
### 7. WireMessage Versioning
|
||||
**What:** Add a version field or envelope around the bincode `WireMessage` so old clients don't crash on new message types.
|
||||
**Why:** Adding a new enum variant to `WireMessage` is a breaking change for all existing clients.
|
||||
**Effort:** 1 day (careful, touches everything)
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Prefix with version byte before bincode, or switch to a self-describing format (protobuf, msgpack)?
|
||||
- [ ] How do old clients handle unknown message types — ignore silently or show "update required"?
|
||||
- [ ] Is this the right time to consider protobuf migration for the wire format?
|
||||
|
||||
---
|
||||
|
||||
## Priority: Normal (Features)
|
||||
|
||||
### 8. Mule Binary Implementation
|
||||
**What:** Build the `warzone-mule` binary for physical message delivery between disconnected networks.
|
||||
**Why:** Core warzone use case — the design is complete (DESIGN.md section 4) but no code exists.
|
||||
**Effort:** 3-5 days
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Start with USB/file transport only, or include Bluetooth from the start?
|
||||
- [ ] Do we need a mule GUI, or is CLI sufficient?
|
||||
- [ ] How do we test this? Two isolated Docker networks? Physical devices?
|
||||
- [ ] Should the mule have its own identity (keypair), or is it anonymous?
|
||||
|
||||
---
|
||||
|
||||
### 9. libsignal Migration Assessment
|
||||
**What:** Evaluate replacing our custom X3DH + Double Ratchet with libsignal-client.
|
||||
**Why:** Battle-tested, audited. But has trade-offs.
|
||||
**Effort:** 1-2 weeks if we decide to do it
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Can we accept the BoringSSL C dependency? (Breaks pure Rust, complicates cross-compilation)
|
||||
- [ ] libsignal doesn't support WASM — do we keep dual implementations (libsignal native + our ratchet for WASM)?
|
||||
- [ ] Is the storage trait adaptation worth it? Their `IdentityKeyStore`/`SessionStore` are different from ours.
|
||||
- [ ] Would an audit of our implementation be cheaper and better?
|
||||
|
||||
---
|
||||
|
||||
### 10. featherChat as OIDC Identity Provider
|
||||
**What:** Add OIDC provider endpoints so external services (Authentik, Keycloak, Grafana) can use featherChat for SSO.
|
||||
**Why:** Makes featherChat the identity backbone for an entire organization.
|
||||
**Effort:** 1-2 weeks (see IDP_SMART_CONTRACT.md)
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] OIDC only, or also SAML for enterprise environments?
|
||||
- [ ] What claims to include in the JWT? (fingerprint, alias, eth_address, groups)
|
||||
- [ ] Should Authentik integration be a priority, or generic OIDC first?
|
||||
- [ ] Do we need a consent screen / authorization UI?
|
||||
|
||||
---
|
||||
|
||||
### 11. Smart Contract Access Control
|
||||
**What:** Deploy a Solidity contract for on-chain permission management (server/group/feature access).
|
||||
**Why:** Decentralized, auditable, censorship-resistant permissions.
|
||||
**Effort:** 3-4 weeks (see IDP_SMART_CONTRACT.md)
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Which L2? Arbitrum, Base, Polygon, or deploy our own?
|
||||
- [ ] Who pays gas? Admin only, or users too?
|
||||
- [ ] Is NFT-gated access a priority, or start with simple ACL?
|
||||
- [ ] How do we handle users who don't have a wallet? (featherChat-managed vs self-custody)
|
||||
|
||||
---
|
||||
|
||||
### 12. DNS Federation
|
||||
**What:** Server discovery via DNS TXT records, server-to-server message relay.
|
||||
**Why:** Multiple servers, no single point of failure.
|
||||
**Effort:** 2-3 weeks (Phase 3)
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Do we run our own DNS server, or use existing infrastructure?
|
||||
- [ ] DNSSEC required or optional?
|
||||
- [ ] How do we handle split-brain (two servers think they're authoritative for the same user)?
|
||||
- [ ] Is gossip discovery (no DNS) a sufficient fallback for warzone scenarios?
|
||||
|
||||
---
|
||||
|
||||
### 13. WarzonePhone Integration
|
||||
**What:** Shared identity + call signaling through featherChat (see WZP_INTEGRATION.md).
|
||||
**Why:** One seed, one identity, chat + calls.
|
||||
**Effort:** 4-8 weeks across 4 phases
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Fix the HKDF info string mismatch first (`"warzone-ed25519"` vs `"warzone-ed25519-identity"`)?
|
||||
- [ ] Who aligns — featherChat or WZP? (featherChat has more users/data to migrate)
|
||||
- [ ] Start with shared identity only (Phase A), or jump to signaling (Phase B)?
|
||||
- [ ] QUIC transport (WZP's choice) — should featherChat also adopt QUIC for messaging?
|
||||
|
||||
---
|
||||
|
||||
## Priority: Low (Polish/Nice-to-Have)
|
||||
|
||||
### 14. Message Search
|
||||
**What:** Full-text search across local message history.
|
||||
**Why:** "What was that config someone shared last week?"
|
||||
**Effort:** 1 day (sled scan + substring match), 1 week (proper full-text index)
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Simple substring search, or proper full-text (tantivy crate)?
|
||||
- [ ] Search across all contacts, or per-peer?
|
||||
- [ ] Web client search too, or CLI only?
|
||||
|
||||
---
|
||||
|
||||
### 15. Read Receipts
|
||||
**What:** Currently we have Delivered receipts. Add Read receipts (when user scrolls to/sees the message).
|
||||
**Why:** Users want to know if their message was read, not just delivered.
|
||||
**Effort:** Half a day
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should read receipts be opt-in (privacy sensitive)?
|
||||
- [ ] What constitutes "read"? Message visible in viewport for N seconds?
|
||||
- [ ] TUI: is "displayed on screen" equivalent to "read"?
|
||||
|
||||
---
|
||||
|
||||
### 16. Typing Indicators
|
||||
**What:** Show "User is typing..." when a peer is composing a message.
|
||||
**Why:** UX polish.
|
||||
**Effort:** Half a day
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Privacy concern — do we want to leak typing activity?
|
||||
- [ ] Should this be opt-in?
|
||||
- [ ] Encrypted or plaintext? (Plaintext is simpler, typing indicators aren't sensitive)
|
||||
|
||||
---
|
||||
|
||||
### 17. Message Reactions (Emoji)
|
||||
**What:** React to a message with an emoji instead of a full reply.
|
||||
**Why:** Quick acknowledgment without cluttering the chat.
|
||||
**Effort:** 1 day
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] New WireMessage variant, or encode as a special text message?
|
||||
- [ ] Display inline (next to message) or as a separate line?
|
||||
- [ ] Multiple reactions per message?
|
||||
|
||||
---
|
||||
|
||||
### 18. Voice Messages as Attachments
|
||||
**What:** Record audio in the client, send as file attachment.
|
||||
**Why:** Voice messages are critical in field use. Uses existing file transfer — no new protocol needed.
|
||||
**Effort:** 1 day (CLI: pipe from mic, Web: MediaRecorder API)
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Codec? Opus at 16kbps is good. Or Codec2 for LoRa-compatible ultra-low bitrate?
|
||||
- [ ] Max duration? 60 seconds? 5 minutes?
|
||||
- [ ] Inline playback in web client, or download-only?
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.0.20*
|
||||
*To work on a task: answer the questions first, then implement.*
|
||||
|
||||
---
|
||||
|
||||
## WarzonePhone Integration Tasks (featherChat side)
|
||||
|
||||
These tasks describe concrete changes needed in the featherChat codebase to enable integration with WarzonePhone (WZP). Each references actual WZP source code that will consume or produce the integration point.
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-1. Add `CallSignal` WireMessage Variant
|
||||
|
||||
**What to change:**
|
||||
- `warzone-protocol/src/message.rs:46-104` — Add a new variant to the `WireMessage` enum:
|
||||
```rust
|
||||
CallSignal {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
signal: Vec<u8>, // opaque serialized wzp_proto::SignalMessage (JSON bytes)
|
||||
},
|
||||
```
|
||||
- `warzone-server/src/routes/ws.rs:25-41` — Add match arm to `extract_message_id()`:
|
||||
```rust
|
||||
WireMessage::CallSignal { id, .. } => Some(id),
|
||||
```
|
||||
- Any `match` on `WireMessage` across the codebase (search for exhaustive pattern matches on `WireMessage`) must gain a `CallSignal` arm.
|
||||
|
||||
**Why:** WZP call signaling (`CallOffer`, `CallAnswer`, `IceCandidate`, `Hangup`) needs to travel through featherChat's E2E encrypted Double Ratchet channel. The `signal` field carries an opaque JSON blob from `wzp-proto/src/packet.rs:249-310` (`SignalMessage` enum). The featherChat server never decrypts or interprets it — it is just another encrypted blob routed via the existing WS relay.
|
||||
|
||||
**WZP code that produces/consumes this:**
|
||||
- `wzp-client/src/handshake.rs:35-44` — caller sends `SignalMessage::CallOffer` (identity_pub, ephemeral_pub, signature, supported_profiles)
|
||||
- `wzp-relay/src/handshake.rs:71-77` — callee sends `SignalMessage::CallAnswer`
|
||||
- `wzp-proto/src/packet.rs:275-278` — `SignalMessage::IceCandidate { candidate: String }`
|
||||
- `wzp-proto/src/packet.rs:298-299` — `SignalMessage::Hangup { reason: HangupReason }`
|
||||
|
||||
**Effort:** 2-4 hours. The `WireMessage` enum already has 7 variants; adding one more is mechanical. The server treats all variants identically (opaque bincode blobs routed by fingerprint).
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should `CallSignal` carry a `target_fingerprint` for the callee, or rely on the outer routing (the 64-byte hex fingerprint prefix in WS binary frames at ws.rs:108-109)?
|
||||
- [ ] Task 7 in FUTURE_TASKS.md (WireMessage Versioning) — should we version the wire format before adding new variants, or add `CallSignal` now and version later?
|
||||
- [ ] Should the `signal` field be `Vec<u8>` (raw JSON) or a typed `serde_json::Value`? Raw bytes preserve opacity but typed allows server-side validation of message structure.
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-2. Call State Management on the Server
|
||||
|
||||
**What to change:**
|
||||
- `warzone-server/src/state.rs` — Add call tracking to `AppState`:
|
||||
```rust
|
||||
/// Active calls: call_id -> CallState
|
||||
pub calls: Arc<Mutex<HashMap<String, CallState>>>,
|
||||
```
|
||||
Where `CallState` tracks: caller fingerprint, callee fingerprint, start time, state (ringing/active/ended).
|
||||
- `warzone-server/src/db.rs` — Add a `calls` sled tree for persistent call history (missed calls, durations).
|
||||
- New file `warzone-server/src/routes/calls.rs` — REST endpoints:
|
||||
- `POST /v1/calls/initiate` — register a call intent (returns call_id)
|
||||
- `GET /v1/calls/:id` — get call state
|
||||
- `POST /v1/calls/:id/end` — mark call as ended
|
||||
- `GET /v1/calls/active` — list active calls for a fingerprint
|
||||
|
||||
**Why:** WZP currently has no concept of call routing through featherChat. The relay (`wzp-relay/src/room.rs:73-127`) manages rooms by transport-level connections, not by user identity. featherChat needs to know which user is calling whom so it can: (a) route `CallSignal` messages to the correct recipient, (b) show "incoming call" UI, (c) store missed calls, (d) enforce authorization (only group members can call each other).
|
||||
|
||||
**WZP code that maps to this:**
|
||||
- `wzp-proto/src/session.rs:10-24` — `SessionState` enum (Idle, Connecting, Handshaking, Active, Rekeying, Closed) — featherChat's CallState should mirror this progression.
|
||||
- `wzp-relay/src/session_mgr.rs:13-24` — `RelaySession` tracks per-session state on the relay side.
|
||||
|
||||
**Effort:** 1-2 days. Requires new routes, new DB tree, state management. The `groups.rs` pattern (load_group/save_group with sled) can be reused.
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should call state be purely in-memory (like CHALLENGES in auth.rs:54) or persistent in sled (like groups)?
|
||||
- [ ] Call timeout — how long before an unanswered call is auto-cancelled? 30 seconds? 60?
|
||||
- [ ] Multi-device: if a user has multiple WS connections (state.rs:14, `Vec<WsSender>`), should all devices ring?
|
||||
- [ ] Should there be a limit on concurrent active calls per user?
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-3. WebSocket Call Signaling Handler
|
||||
|
||||
**What to change:**
|
||||
- `warzone-server/src/routes/ws.rs:102-167` — In the `Message::Binary` and `Message::Text` handlers, after deserializing a `WireMessage`, detect the `CallSignal` variant and perform call-specific logic:
|
||||
1. If `CallSignal` with a `CallOffer` signal: create/update the CallState (from WZP-FC-2), push to the callee's WS, and if callee is offline, queue it (existing queue:fingerprint:uuid pattern at ws.rs:124).
|
||||
2. If `CallSignal` with a `Hangup` signal: update CallState to ended, notify the other party.
|
||||
3. All other `CallSignal` types: route as opaque blobs (same as existing message routing).
|
||||
|
||||
**Why:** The existing WS handler (ws.rs:64-189) already routes binary messages by fingerprint prefix. Call signaling needs the same routing but with additional server-side awareness of call lifecycle. Without this, the server cannot generate "missed call" notifications or enforce call authorization.
|
||||
|
||||
**WZP code that will send these messages:**
|
||||
- The integrated client will serialize `wzp_proto::SignalMessage` to JSON, wrap it in `WireMessage::CallSignal`, serialize with bincode, then prepend the 64-hex-char recipient fingerprint — same as existing message sending (ws.rs:108-128).
|
||||
|
||||
**Effort:** Half a day. Mostly adding conditional logic inside the existing `Message::Binary` handler. The routing infrastructure already exists.
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should the server peek inside the `signal` field to determine if it is a CallOffer vs Hangup? Or should the server be completely opaque and rely on separate REST calls (WZP-FC-2) for state tracking?
|
||||
- [ ] If opaque: how does the server know a call was missed (callee never connected)?
|
||||
- [ ] Priority: should CallSignal messages bypass the dedup tracker (state.rs:17-48) or use it? Duplicate call offers could be confusing.
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-4. Auth Token Issuance for WZP
|
||||
|
||||
**What to change:**
|
||||
- `warzone-server/src/routes/auth.rs` — Add a new endpoint:
|
||||
```rust
|
||||
.route("/auth/validate", post(validate_token_endpoint))
|
||||
```
|
||||
This wraps the existing `validate_token()` function (auth.rs:177-186) as an HTTP endpoint:
|
||||
```
|
||||
POST /v1/auth/validate
|
||||
Body: { "token": "hex..." }
|
||||
Response: { "valid": true, "fingerprint": "a3f8...", "expires_at": ... }
|
||||
or { "valid": false }
|
||||
```
|
||||
- Optionally, add a scoped token variant: `POST /v1/auth/issue-service-token` that issues a short-lived token (5 min) specifically for WZP relay authentication, with a `"service": "wzp"` claim in the JSON payload stored in the `tokens` sled tree.
|
||||
|
||||
**Why:** WZP relays need a way to verify that a connecting client is a legitimate featherChat user. Currently WZP has NO server-side auth (`wzp-relay/src/main.rs:159-231` accepts any QUIC connection). The validate endpoint lets the WZP relay call featherChat's server to confirm a bearer token is valid before allowing the QUIC call session.
|
||||
|
||||
**WZP code that will use this:**
|
||||
- Currently none — WZP would need to add a pre-connection auth step where the client presents a featherChat bearer token. See "Suggestions for WarzonePhone" section below.
|
||||
- `wzp-crypto/src/handshake.rs:79-88` — WZP already verifies Ed25519 identity signatures during the QUIC handshake. The featherChat token adds a second layer: "this identity is registered with the featherChat server."
|
||||
|
||||
**Effort:** 2-4 hours. The `validate_token()` function already exists and works; this is wrapping it as a route + adding optional scoped tokens.
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should the validate endpoint require auth itself (only WZP relay can call it)? Or is it public?
|
||||
- [ ] Should we use HMAC-signed JWTs instead of random hex tokens? JWTs can be verified without a round-trip to the server. The WZP relay could verify locally if it knows the signing key.
|
||||
- [ ] Token scope: should a single featherChat token work for both messaging and calling, or should calls require a separate scoped token?
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-5. Group-to-Room Mapping
|
||||
|
||||
**What to change:**
|
||||
- `warzone-server/src/routes/groups.rs` — Add endpoint:
|
||||
```rust
|
||||
.route("/groups/:name/call", post(initiate_group_call))
|
||||
```
|
||||
This creates a WZP room ID from the group name (e.g., `room_id = SHA-256("featherchat-group:" + group_name)[:16]` as hex), stores it in the call state, and pushes a `CallSignal` with `CallOffer` to all online group members.
|
||||
- `warzone-server/src/routes/groups.rs:270-297` (`get_members`) — Extend response to include online/offline status (requires checking `state.connections` from state.rs:13-14).
|
||||
|
||||
**Why:** WZP rooms (`wzp-relay/src/room.rs:73-127`) are identified by a string name passed as QUIC SNI (`wzp-relay/src/main.rs:173-179`). featherChat groups (`groups.rs:23-28`, `GroupInfo { name, creator, members }`) are the natural unit for group calls. Mapping group name to room name lets clients know which WZP relay room to connect to.
|
||||
|
||||
**WZP code that consumes this:**
|
||||
- `wzp-relay/src/main.rs:173-179` — room name extracted from QUIC SNI (`connection.handshake_data()...server_name`).
|
||||
- `wzp-relay/src/room.rs:85-93` — `RoomManager::join(room_name, ...)` — the room_name string is the join key.
|
||||
- `wzp-web/src/main.rs:158-161` — web bridge passes room name as SNI when connecting to relay.
|
||||
|
||||
**Effort:** 1 day. The group membership infrastructure exists; the new logic is generating a deterministic room ID and coordinating the call invitation fanout (similar to `send_to_group` at groups.rs:171-209).
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Room ID derivation: hash of group name (deterministic, predictable) or random (requires distribution)? Hash is simpler but leaks group name to the relay via SNI.
|
||||
- [ ] Should the relay enforce room membership, or is room access open (anyone with the room name can join)? Currently WZP rooms are open (room.rs:91).
|
||||
- [ ] Max participants per group call? WZP relay is SFU — bandwidth scales as O(N) per participant.
|
||||
- [ ] Should group calls use featherChat's Sender Key mechanism (`WireMessage::GroupSenderKey` at message.rs:87-95) for group call signaling encryption?
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-6. Presence / Online Status
|
||||
|
||||
**What to change:**
|
||||
- `warzone-server/src/state.rs:50-105` — Add a public method:
|
||||
```rust
|
||||
pub async fn is_online(&self, fingerprint: &str) -> bool {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint).map(|s| !s.is_empty()).unwrap_or(false)
|
||||
}
|
||||
```
|
||||
- New route in a `routes/presence.rs` or extending `routes/keys.rs`:
|
||||
```
|
||||
GET /v1/presence/:fingerprint -> { "online": true/false, "devices": 2 }
|
||||
POST /v1/presence/batch -> [{ "fingerprint": "...", "online": true }, ...]
|
||||
```
|
||||
- `warzone-server/src/routes/ws.rs:64-189` — On WS connect/disconnect, emit a presence event (optionally push to subscribers).
|
||||
|
||||
**Why:** Before initiating a call, the client needs to know if the callee is reachable. Currently `state.connections` (state.rs:13-14) tracks WS connections but this data is not exposed via any API. WZP's client (`wzp-client/src/cli.rs`) currently connects directly to the relay without checking if the peer is available.
|
||||
|
||||
**WZP code that benefits:**
|
||||
- The integrated client would check presence before constructing `SignalMessage::CallOffer` (`wzp-client/src/handshake.rs:34-44`). If the callee is offline, the client can skip the call or send a "missed call" notification instead.
|
||||
|
||||
**Effort:** Half a day for basic online/offline. 1-2 days for real-time presence subscriptions.
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Privacy: should presence be opt-in? Some users may not want others to know they are online.
|
||||
- [ ] Should presence updates be push (WS event when a contact comes online) or poll (REST endpoint)?
|
||||
- [ ] Should we expose device count, or just online/offline boolean?
|
||||
- [ ] Auth: should presence queries require a valid bearer token?
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-7. Missed Call Notifications (Store-and-Forward)
|
||||
|
||||
**What to change:**
|
||||
- `warzone-server/src/routes/ws.rs:120-128` — When a `CallSignal` with a `CallOffer` signal cannot be delivered (callee offline, `push_to_client` returns false), store a "missed call" record in sled instead of (or in addition to) queuing the raw message.
|
||||
- `warzone-server/src/db.rs` — Add a `missed_calls` sled tree. Schema: key = `missed:{callee_fp}:{timestamp}`, value = JSON `{ caller_fp, timestamp, call_id }`.
|
||||
- New endpoint: `GET /v1/calls/missed` — returns missed calls for the authenticated user.
|
||||
- On WS reconnect (ws.rs:70-85, queue drain loop): include missed call notifications alongside queued messages.
|
||||
|
||||
**Why:** In warzone/field scenarios, users may be offline when called. They need to know who tried to reach them. This mirrors the existing store-and-forward for text messages (queue:fingerprint:uuid pattern at ws.rs:124) but specifically for call attempts.
|
||||
|
||||
**WZP code that maps to this:**
|
||||
- `wzp-proto/src/packet.rs:298-299` — `SignalMessage::Hangup { reason: HangupReason::Timeout }` would be generated by the caller after no answer. featherChat should interpret this as a missed call.
|
||||
- `wzp-proto/src/packet.rs:303-310` — `HangupReason::Busy`, `HangupReason::Declined` should produce different notification types.
|
||||
|
||||
**Effort:** Half a day. Reuses existing queue infrastructure.
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should missed calls expire? After 7 days? 30 days?
|
||||
- [ ] Should the caller get a "call not delivered" notification if the callee is offline?
|
||||
- [ ] Should missed calls from the same caller within N minutes be collapsed into one?
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-8. Cross-Project Identity Verification Test
|
||||
|
||||
**What to change:**
|
||||
- New integration test (could live in `warzone-protocol/tests/wzp_identity_compat.rs` or a shared test crate):
|
||||
1. Generate a seed with featherChat's `Seed::from_bytes()` (`identity.rs:24-26`)
|
||||
2. Derive identity with `seed.derive_identity()` (`identity.rs:29-47`)
|
||||
3. Derive identity with WZP's `WarzoneKeyExchange::from_identity_seed()` (`wzp-crypto/src/handshake.rs:32-53`)
|
||||
4. Assert: `featherchat_identity.signing.verifying_key().as_bytes() == wzp_identity.identity_public_key()`
|
||||
5. Assert: featherChat fingerprint == WZP fingerprint
|
||||
6. Cross-sign: sign with featherChat key, verify with WZP's `WarzoneKeyExchange::verify()`
|
||||
7. Cross-sign: sign with WZP key, verify with featherChat's Ed25519 verifier
|
||||
|
||||
**Critical blocker:** This test WILL FAIL today because of the HKDF info string mismatch:
|
||||
- featherChat uses `"warzone-ed25519"` (`identity.rs:31`) and `"warzone-x25519"` (`identity.rs:38`)
|
||||
- WZP uses `"warzone-ed25519"` (`handshake.rs:36`) and `"warzone-x25519"` (`handshake.rs:43`)
|
||||
|
||||
**Wait — re-reading the actual WZP code:** The WZP code at `wzp-crypto/src/handshake.rs:36` uses `b"warzone-ed25519"` (NOT `b"warzone-ed25519-identity"` as documented in WZP_INTEGRATION.md). The WZP code was apparently already updated to match featherChat. Let me confirm:
|
||||
- `handshake.rs:36`: `hk.expand(b"warzone-ed25519", ...)` — matches featherChat
|
||||
- `handshake.rs:43`: `hk.expand(b"warzone-x25519", ...)` — matches featherChat
|
||||
|
||||
**However**, featherChat uses `hkdf_derive()` which calls `Hkdf::new(Some(salt), ikm)` with `salt=b""` (non-empty empty bytes), while WZP uses `Hkdf::new(None, seed)` (no salt). This is a REAL mismatch: `Hkdf::new(Some(b""), seed)` vs `Hkdf::new(None, seed)` produce DIFFERENT outputs because HKDF treats `None` salt as all-zero bytes of hash length (32 bytes for SHA-256), while `Some(b"")` is a zero-length salt.
|
||||
|
||||
**This test is the single most critical integration task.** It validates or invalidates the "same seed = same identity" assumption.
|
||||
|
||||
**Effort:** 2-4 hours to write the test. Fix depends on which side adjusts the HKDF salt parameter.
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Which HKDF salt convention should be canonical? featherChat's `Some(b"")` or WZP's `None`? Check what the actual `hkdf_derive` function does — it may already handle this.
|
||||
- [ ] If the salt mismatch is confirmed: who changes? featherChat has deployed users; WZP is v0.1.0 with no deployed users. WZP should change.
|
||||
- [ ] Should this test live in featherChat's repo (as a dev-dependency on wzp-crypto) or in a separate integration test crate?
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-9. HKDF Salt Alignment Investigation
|
||||
|
||||
**What to change:**
|
||||
- `warzone-protocol/src/crypto.rs` (wherever `hkdf_derive` is defined) — Audit the exact HKDF construction. The function signature is `hkdf_derive(&self.0, b"", b"warzone-ed25519", 32)` at `identity.rs:31`. If it passes `b""` as salt to `Hkdf::new(Some(b""), ikm)`, this differs from WZP's `Hkdf::new(None, seed)`.
|
||||
- Depending on findings, either:
|
||||
- Document the current behavior as canonical and request WZP align, OR
|
||||
- Add a compatibility mode that accepts both derivation paths during a migration period.
|
||||
|
||||
**Why:** This is a prerequisite for WZP-FC-8 and ALL other integration tasks. If the same seed produces different keys in featherChat vs WZP, no integration is possible without key migration.
|
||||
|
||||
**WZP code reference:**
|
||||
- `wzp-crypto/src/handshake.rs:34`: `Hkdf::<Sha256>::new(None, seed)` — uses None salt.
|
||||
- featherChat `identity.rs:31`: `hkdf_derive(&self.0, b"", b"warzone-ed25519", 32)` — passes `b""` as salt parameter.
|
||||
|
||||
**Effort:** 1-2 hours investigation, potentially 0 code change if both resolve to the same thing (depends on `hkdf_derive` implementation).
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Read the `hkdf_derive` function implementation — does it pass `b""` as `Some(b"")` or convert it to `None`?
|
||||
- [ ] If different: test with a known seed to confirm outputs differ.
|
||||
|
||||
---
|
||||
|
||||
### WZP-FC-10. WZP Web Bridge Shared Authentication
|
||||
|
||||
**What to change:**
|
||||
- `warzone-server/src/routes/` — Add CORS headers or a proxy endpoint that allows `wzp-web` (running on port 8080, see `wzp-web/src/main.rs:51-52`) to authenticate against featherChat (port 7700).
|
||||
- Alternatively, add a route `GET /v1/wzp/relay-config` that returns the WZP relay address and a time-limited auth token for the WZP relay, so the web client can connect to the relay with featherChat-issued credentials.
|
||||
|
||||
**Why:** The WZP web bridge (`wzp-web/src/main.rs`) currently has NO authentication. Any browser can open a WebSocket to `/ws/<room>` (`wzp-web/src/main.rs:93`) and join any room. It also has its own independent axum server with no connection to featherChat's identity system. The web client HTML (`wzp-web/static/index.html:85-101`) requests mic access and connects to a room by name — no identity verification whatsoever.
|
||||
|
||||
**WZP code reference:**
|
||||
- `wzp-web/src/main.rs:141-258` — `handle_ws()` immediately connects to the relay with no auth check.
|
||||
- `wzp-web/static/index.html:106-111` — WebSocket URL constructed from room name only, no auth token.
|
||||
|
||||
**Effort:** 1-2 days. Requires CORS configuration, token passing from featherChat web client to WZP web bridge, and potentially merging the two web servers.
|
||||
|
||||
**Questions before starting:**
|
||||
- [ ] Should WZP web bridge be a separate server or integrated into featherChat's axum server (as additional routes)?
|
||||
- [ ] Can we reuse featherChat's WASM identity (`warzone-wasm`) in the WZP web client?
|
||||
- [ ] If separate servers: how do we share auth? Redirect? Shared cookie domain? Token in URL query param?
|
||||
|
||||
---
|
||||
|
||||
## Suggestions for WarzonePhone (other side)
|
||||
|
||||
These are advisory changes WZP should make to enable integration. We do not control the WZP codebase.
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-1. Align HKDF Salt Convention
|
||||
|
||||
**What to change:** `wzp-crypto/src/handshake.rs:34`
|
||||
```rust
|
||||
// Current:
|
||||
let hk = Hkdf::<Sha256>::new(None, seed);
|
||||
// Should be (if featherChat uses non-None salt):
|
||||
let hk = Hkdf::<Sha256>::new(Some(&[]), seed); // or whatever featherChat uses
|
||||
```
|
||||
|
||||
**Why:** This is the CRITICAL prerequisite for shared identity. If featherChat's `hkdf_derive` passes `Some(b"")` vs WZP's `None`, the same seed produces different keys. The `hkdf` crate (v0.12, used by both) treats `None` as a zero-filled salt of hash length (32 bytes for SHA-256), while `Some(&[])` is an empty salt — these ARE different per RFC 5869 Section 2.2.
|
||||
|
||||
**Maps to featherChat task:** WZP-FC-8, WZP-FC-9.
|
||||
|
||||
**Recommendation:** WZP should align to featherChat's convention since featherChat has deployed users. This is a one-line change but invalidates any existing WZP identities (acceptable at v0.1.0).
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-2. Accept featherChat Bearer Token on Relay Connection
|
||||
|
||||
**What to change:**
|
||||
- `wzp-relay/src/main.rs:159-231` — Before accepting a QUIC connection into a room, require the client to present a featherChat bearer token via the reliable signaling stream. Add a new `SignalMessage` variant:
|
||||
```rust
|
||||
AuthToken { token: String },
|
||||
```
|
||||
The relay would call featherChat's `POST /v1/auth/validate` endpoint (WZP-FC-4) to verify the token, extracting the fingerprint. Reject the connection if validation fails.
|
||||
|
||||
- `wzp-relay/src/main.rs:173-179` — Currently, room name comes from QUIC SNI with no validation. After auth, verify that the authenticated fingerprint is a member of the corresponding featherChat group.
|
||||
|
||||
**Why:** Currently WZP relay accepts any QUIC connection with no authentication. The `RoomManager::join()` at `room.rs:85-93` takes any transport and adds it to the room. This is fine for testing but unacceptable in production — anyone who knows the relay address can join any call.
|
||||
|
||||
**Maps to featherChat task:** WZP-FC-4, WZP-FC-5.
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-3. Add Signaling Bridge Mode to WZP Client
|
||||
|
||||
**What to change:**
|
||||
- `wzp-client/src/handshake.rs:17-81` — The `perform_handshake()` function currently sends `SignalMessage::CallOffer` directly over the QUIC transport. For featherChat integration, add an alternative mode where signaling goes through featherChat's encrypted WS channel:
|
||||
1. Client serializes `SignalMessage::CallOffer` to JSON
|
||||
2. Wraps it in featherChat's `WireMessage::CallSignal`
|
||||
3. Encrypts with Double Ratchet (existing featherChat session)
|
||||
4. Sends via featherChat WS
|
||||
5. Callee decrypts, extracts `SignalMessage`, replies via same path
|
||||
6. Once both parties know each other's ephemeral keys, they connect directly via QUIC for media
|
||||
|
||||
- `wzp-client/src/lib.rs` — Add a new public function: `perform_handshake_via_featherchat()` that takes both a `MediaTransport` (for eventual QUIC media) and a featherChat WS connection (for signaling).
|
||||
|
||||
**Why:** Currently WZP assumes direct QUIC connectivity for both signaling and media (`wzp-client/src/handshake.rs:45` calls `transport.send_signal()` which sends over QUIC reliable stream). This fails behind NATs without relay. Using featherChat for signaling enables NAT traversal: signaling goes through featherChat's always-reachable server, while media goes P2P or via WZP relay.
|
||||
|
||||
**Maps to featherChat task:** WZP-FC-1, WZP-FC-3.
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-4. Room Access Control in Relay
|
||||
|
||||
**What to change:**
|
||||
- `wzp-relay/src/room.rs:85-93` — `RoomManager::join()` currently accepts any transport. Add an `authorized_fingerprints: Option<HashSet<String>>` field to `Room` struct (room.rs:32-34). If set, reject joins from fingerprints not in the set.
|
||||
- `wzp-relay/src/main.rs:215-218` — After auth (WZP-S-2), check room authorization before calling `mgr.join()`.
|
||||
|
||||
**Why:** featherChat groups have membership lists (`groups.rs:23-28`, `GroupInfo.members: Vec<String>`). When a group call is initiated, only group members should be able to join the corresponding WZP room. Without this, any authenticated user can join any room by guessing the room name.
|
||||
|
||||
**Maps to featherChat task:** WZP-FC-5.
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-5. Expose Identity Public Key in Relay Handshake
|
||||
|
||||
**What to change:**
|
||||
- `wzp-relay/src/handshake.rs:19-80` — The `accept_handshake()` function receives the caller's `identity_pub` in the `CallOffer` but does not expose it after the handshake. The return type is `(Box<dyn CryptoSession>, QualityProfile)`. Change to also return the caller's identity public key and fingerprint:
|
||||
```rust
|
||||
pub async fn accept_handshake(...) -> Result<(Box<dyn CryptoSession>, QualityProfile, [u8; 32], [u8; 16]), ...>
|
||||
// ^^identity ^^fingerprint
|
||||
```
|
||||
|
||||
**Why:** The relay needs to know WHO connected (not just that they have a valid session key). This fingerprint is needed for: room authorization (WZP-S-4), presence reporting back to featherChat, and logging.
|
||||
|
||||
**Maps to featherChat task:** WZP-FC-2, WZP-FC-5, WZP-FC-6.
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-6. Web Bridge Integration with featherChat Web Client
|
||||
|
||||
**What to change:**
|
||||
- `wzp-web/src/main.rs` — Instead of running as a standalone server (port 8080), support being mounted as a sub-router inside featherChat's axum server. This means extracting the axum `Router` and WebSocket handler into a library function that featherChat can import:
|
||||
```rust
|
||||
pub fn wzp_web_routes(relay_addr: SocketAddr) -> Router { ... }
|
||||
```
|
||||
- `wzp-web/static/index.html:85-101` — The `startCall()` function should accept an auth token from the parent page (featherChat web client) and include it in the WebSocket connection URL or as a query parameter.
|
||||
- The web client currently does its own AudioWorklet setup (`index.html:172-214`). If integrated into featherChat's web client, this would need to coexist with featherChat's WASM identity and UI.
|
||||
|
||||
**Why:** Running two separate web servers creates deployment complexity and prevents shared authentication. featherChat's web client already has a WASM-based identity system; the WZP web bridge should leverage it instead of being anonymous.
|
||||
|
||||
**Maps to featherChat task:** WZP-FC-10.
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-7. Add `wzp-proto` as Optional Dependency for featherChat
|
||||
|
||||
**What to change:**
|
||||
- `wzp-proto/Cargo.toml` — Ensure `wzp-proto` can be compiled independently (no transitive dependency on `quinn`, `audiopus`, or `codec2`). Currently it depends on `bytes`, `thiserror`, `async-trait`, `tokio`, `serde` — all lightweight.
|
||||
- Publish `wzp-proto` types that featherChat needs: `SignalMessage`, `HangupReason`, `QualityProfile`, `QualityReport`.
|
||||
|
||||
**Why:** featherChat's protocol crate (`warzone-protocol`) needs to understand `SignalMessage` to properly type the `CallSignal::signal` field. Two options:
|
||||
1. Keep `signal` as opaque `Vec<u8>` (no dependency needed, but no type safety)
|
||||
2. Add `wzp-proto` as optional dependency and use `SignalMessage` directly
|
||||
|
||||
Option 1 is simpler for Phase 1. Option 2 enables server-side call state inference.
|
||||
|
||||
**Maps to featherChat task:** WZP-FC-1.
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-8. CLI Client Seed Input
|
||||
|
||||
**What to change:**
|
||||
- `wzp-client/src/cli.rs:45-131` — The CLI currently has no `--seed` or `--mnemonic` flag. Add:
|
||||
```
|
||||
--seed <hex> Use this 32-byte hex seed for identity
|
||||
--mnemonic <words> Use BIP39 mnemonic for identity (same as featherChat)
|
||||
```
|
||||
Currently `wzp-client/src/cli.rs:133-179` (`main()`) connects to the relay with NO identity at all — it sends unencrypted media. The handshake functions (`wzp-client/src/handshake.rs`) exist but are never called from `cli.rs`.
|
||||
|
||||
**Why:** For shared identity to work, the WZP CLI must accept the same BIP39 mnemonic that featherChat uses, derive the identity via `WarzoneKeyExchange::from_identity_seed()`, and perform the authenticated handshake before sending media.
|
||||
|
||||
**Maps to featherChat task:** WZP-FC-8.
|
||||
|
||||
---
|
||||
|
||||
### WZP-S-9. Hardcoded Assumptions That Conflict with Integration
|
||||
|
||||
Several WZP patterns assume standalone operation:
|
||||
|
||||
1. **No auth on relay** (`wzp-relay/src/main.rs:159-231`): The accept loop processes every incoming QUIC connection without any identity check. `run_participant()` at room.rs:131 starts forwarding immediately.
|
||||
|
||||
2. **Room names from SNI only** (`wzp-relay/src/main.rs:173-179`): Room names come from the TLS SNI field, which is unencrypted and visible to network observers. For featherChat integration, room names should be opaque hashes, not human-readable group names.
|
||||
|
||||
3. **No signaling before media** (`wzp-client/src/cli.rs:159`): The CLI connects and immediately starts sending media packets (`run_silence`, `run_file_mode`). No handshake, no identity verification. The handshake module exists but is unused.
|
||||
|
||||
4. **Self-signed TLS everywhere** (`wzp-transport/src/config.rs`): Both client and server configs use self-signed certificates with verification disabled. This is fine for testing but means the relay identity cannot be verified.
|
||||
|
||||
5. **Web bridge lacks codec negotiation** (`wzp-web/src/main.rs:169`): The web bridge uses `CallConfig::default()` (GOOD profile, Opus 24k) without negotiation. The relay-side `CallEncoder`/`CallDecoder` may use a different profile than the peer client, causing decode failures.
|
||||
|
||||
6. **No connection to featherChat key registry** (`wzp-crypto/src/handshake.rs:79-88`): `WarzoneKeyExchange::verify()` checks Ed25519 signatures but has no way to verify that the signing key belongs to a known contact. featherChat's key registry (`routes/keys.rs:74-104`, `GET /v1/keys/:fingerprint`) provides this — WZP should query it.
|
||||
|
||||
**Maps to featherChat tasks:** WZP-FC-4, WZP-FC-5, WZP-FC-8.
|
||||
1111
warzone/docs/IDP_SMART_CONTRACT.md
Normal file
1111
warzone/docs/IDP_SMART_CONTRACT.md
Normal file
File diff suppressed because it is too large
Load Diff
542
warzone/docs/INTEGRATION.md
Normal file
542
warzone/docs/INTEGRATION.md
Normal file
@@ -0,0 +1,542 @@
|
||||
# Warzone Messenger (featherChat) — Integration & Extensibility Guide
|
||||
|
||||
**Version:** 0.0.20
|
||||
|
||||
Items marked with **(future)** are designed but not yet implemented.
|
||||
|
||||
---
|
||||
|
||||
## WarzonePhone Integration (future)
|
||||
|
||||
WarzonePhone is envisioned as a separate project for encrypted voice/video calls, sharing infrastructure with the messenger.
|
||||
|
||||
### Shared Components
|
||||
|
||||
- **Identity:** Same BIP39 seed and fingerprint. One identity for messaging + calls.
|
||||
- **Server infrastructure:** Same server hosts both message relay and SRTP/VoIP signaling.
|
||||
- **Pre-key bundles:** Reuse X3DH bundles for call setup (SRTP key exchange).
|
||||
- **Contact list:** Shared aliases and contact metadata.
|
||||
|
||||
### Voice Messages
|
||||
|
||||
Before VoIP is built, voice messages can be sent as file attachments:
|
||||
|
||||
```
|
||||
/file voice-message.opus
|
||||
```
|
||||
|
||||
The `/file` command already supports arbitrary file transfer up to 10 MB. An Opus audio file at 32 kbps allows ~40 minutes per message.
|
||||
|
||||
### Integration Pattern
|
||||
|
||||
```
|
||||
warzone-protocol (shared)
|
||||
│
|
||||
┌─────┴──────┐
|
||||
│ │
|
||||
warzone-client warzone-phone
|
||||
(messaging) (VoIP, future)
|
||||
```
|
||||
|
||||
Both binaries link against `warzone-protocol` for identity, key exchange, and encryption.
|
||||
|
||||
---
|
||||
|
||||
## Ethereum / Web3 Integration
|
||||
|
||||
### Current Implementation (v0.0.20)
|
||||
|
||||
The `ethereum` module in `warzone-protocol` provides:
|
||||
|
||||
- **secp256k1 keypair** derived from the BIP39 seed via `HKDF(seed, info="warzone-secp256k1")`
|
||||
- **Ethereum address** computation: `Keccak-256(uncompressed_pubkey[1:])[-20:]`
|
||||
- **EIP-55 checksummed addresses**
|
||||
- **ECDSA signing and verification** (secp256k1)
|
||||
- CLI command: `warzone eth`
|
||||
- TUI command: `/eth`
|
||||
|
||||
### MetaMask / Wallet Connect (future)
|
||||
|
||||
Planned integration flow:
|
||||
|
||||
```
|
||||
1. User clicks "Connect Wallet" in web client
|
||||
2. Web client requests eth_sign(challenge) from MetaMask
|
||||
3. Server verifies secp256k1 signature
|
||||
4. Server maps Ethereum address → Warzone fingerprint
|
||||
5. Session established
|
||||
|
||||
Challenge: MetaMask signs with secp256k1, but Warzone messaging
|
||||
uses Ed25519/X25519. The wallet connect only proves ownership of
|
||||
the Ethereum address — a separate X3DH session is still needed
|
||||
for E2E encryption.
|
||||
```
|
||||
|
||||
### ENS Resolution (future)
|
||||
|
||||
Planned: resolve ENS names to Warzone fingerprints.
|
||||
|
||||
```
|
||||
@vitalik.eth → resolve ENS → 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
|
||||
→ server lookup → Warzone fingerprint
|
||||
→ /peer @vitalik.eth
|
||||
```
|
||||
|
||||
Implementation would use `alloy` or `ethers-rs` for ENS resolution.
|
||||
|
||||
### Hardware Wallet Support (future)
|
||||
|
||||
Ledger and Trezor natively support secp256k1. Integration plan:
|
||||
|
||||
- Seed lives on the hardware wallet, never exported
|
||||
- Ed25519 signing delegated to device (BIP44 path `m/44'/1234'/0'`)
|
||||
- X25519 derived from Ed25519 or separate derivation path
|
||||
- Session key delegation: sign once per 30 days, client uses delegated key for daily operations
|
||||
|
||||
### Session Delegation (future)
|
||||
|
||||
For hardware wallets that cannot be used for every message:
|
||||
|
||||
```
|
||||
Hardware wallet signs: "I delegate signing to ephemeral key X for 30 days"
|
||||
Client stores ephemeral key in memory
|
||||
All messages signed with ephemeral key
|
||||
Contacts verify delegation chain: HW_pubkey → delegation_cert → ephemeral_sig
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OIDC Integration (future)
|
||||
|
||||
For organizational deployments, an OIDC provider can gate registration and associate corporate identities.
|
||||
|
||||
### Concept
|
||||
|
||||
```
|
||||
1. User authenticates with corporate IdP (Okta, Azure AD, etc.)
|
||||
2. IdP issues OIDC token containing email/groups
|
||||
3. User presents OIDC token to Warzone server during registration
|
||||
4. Server verifies token, associates fingerprint with corporate identity
|
||||
5. Optional: server restricts messaging to verified users only
|
||||
|
||||
Benefits:
|
||||
- Gated registration (only org members can register)
|
||||
- Corporate directory integration (resolve by email)
|
||||
- Audit trail (fingerprint ↔ corporate identity mapping)
|
||||
- Seed recovery via corporate identity (re-register)
|
||||
```
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
```rust
|
||||
// Future: auth middleware
|
||||
async fn register_with_oidc(
|
||||
State(state): State<AppState>,
|
||||
bearer: TypedHeader<Authorization<Bearer>>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let claims = verify_oidc_token(&bearer.token())?;
|
||||
// Associate claims.email with req.fingerprint
|
||||
// Only allow registration if claims are valid
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DNS Federation (future)
|
||||
|
||||
### Server Discovery
|
||||
|
||||
Each Warzone server publishes a DNS TXT record:
|
||||
|
||||
```
|
||||
_warzone._tcp.example.com TXT "v=wz1; endpoint=https://wz.example.com; pubkey=base64..."
|
||||
```
|
||||
|
||||
Other servers discover peers by querying DNS:
|
||||
|
||||
```
|
||||
1. User sends message to user@example.com
|
||||
2. Local server: DNS TXT lookup → _warzone._tcp.example.com
|
||||
3. Parse endpoint URL and server pubkey
|
||||
4. TLS connection, mutual authentication
|
||||
5. Deliver encrypted message blob
|
||||
```
|
||||
|
||||
### Key Transparency
|
||||
|
||||
Users publish their public keys in DNS to prevent server MITM:
|
||||
|
||||
```
|
||||
_wz._id.<SHA256(fingerprint)[:16]>.example.com TXT "v=wz1; fp=a3f8...; pubkey=base64...; sig=base64..."
|
||||
```
|
||||
|
||||
The `sig` field is a self-signature — even the DNS admin cannot forge it without the user's private key.
|
||||
|
||||
### Alias Resolution via DNS (future)
|
||||
|
||||
```
|
||||
_wz._alias.alice.example.com TXT "fp=a3f8c912..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transport Abstraction
|
||||
|
||||
The protocol is transport-agnostic. The `WireMessage` format is identical regardless of how it travels.
|
||||
|
||||
### Current Transports (v0.0.20)
|
||||
|
||||
| Transport | Client→Server | Server→Client | Status |
|
||||
|-----------|---------------|---------------|--------|
|
||||
| HTTPS | POST JSON | GET poll | Implemented |
|
||||
| WebSocket | Binary/JSON | Binary push | Implemented |
|
||||
|
||||
### Planned Transports (future)
|
||||
|
||||
| Transport | Range | Bandwidth | Use Case |
|
||||
|-------------|------------|------------|-----------------------------|
|
||||
| Bluetooth | 10-100m | ~2 Mbps | Mule sync, nearby devices |
|
||||
| LoRa | 2-15 km | 0.3-50 kbps| Emergency text, receipts |
|
||||
| Wi-Fi Direct| ~200m | ~250 Mbps | Local group mesh |
|
||||
| USB/File | Physical | Unlimited | Sneakernet, mule export |
|
||||
|
||||
### LoRa Compact Format (future)
|
||||
|
||||
For LoRa's ~250 byte payload limit:
|
||||
|
||||
```
|
||||
[1] version
|
||||
[1] type (text=0x01, receipt=0x02, beacon=0x03)
|
||||
[8] sender fingerprint (truncated)
|
||||
[8] recipient fingerprint (truncated)
|
||||
[4] timestamp (unix 32-bit)
|
||||
[12] nonce
|
||||
[~216] ciphertext (~200 chars of text)
|
||||
```
|
||||
|
||||
### USB / Sneakernet (future)
|
||||
|
||||
```bash
|
||||
warzone export --since 24h --to /mnt/usb/messages.wz
|
||||
# Carry USB drive to destination
|
||||
warzone import /mnt/usb/messages.wz
|
||||
```
|
||||
|
||||
### Implementing a New Transport
|
||||
|
||||
Define a type that implements the transport interface (conceptual — trait not yet formalized):
|
||||
|
||||
```rust
|
||||
// Future trait
|
||||
trait Transport: Send + Sync {
|
||||
async fn send(&self, endpoint: &str, blob: &[u8]) -> Result<()>;
|
||||
async fn recv(&self) -> Result<Vec<u8>>;
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
```
|
||||
|
||||
The message blob is always a bincode-serialized `WireMessage`. The transport only needs to deliver bytes.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Server Mode (future)
|
||||
|
||||
### Federation
|
||||
|
||||
Servers communicate using mutual TLS and server-to-server protocol:
|
||||
|
||||
```
|
||||
Server A Server B
|
||||
│ │
|
||||
│ DNS lookup: _warzone._tcp.B │
|
||||
│ TLS connect + mutual auth │
|
||||
│ ─── deliver encrypted blob ────────→│
|
||||
│ ←── delivery receipt ───────────────│
|
||||
```
|
||||
|
||||
### Server-to-Server Relay
|
||||
|
||||
When direct connectivity is not available:
|
||||
|
||||
```
|
||||
Server A → Server C (relay) → Server B
|
||||
|
||||
Server C is configured as a relay for B.
|
||||
C queues messages for B until B reconnects.
|
||||
```
|
||||
|
||||
### Gossip Discovery (future)
|
||||
|
||||
Servers share their known peer lists:
|
||||
|
||||
```json
|
||||
{
|
||||
"peers": [
|
||||
{"domain": "wz.example.com", "pubkey": "base64...", "last_seen": 1711443600},
|
||||
{"domain": "chat.org", "pubkey": "base64...", "last_seen": 1711440000}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Mule Protocol (future)
|
||||
|
||||
Physical message relay between disconnected networks:
|
||||
|
||||
1. Mule authenticates with source server
|
||||
2. Mule picks up queued outbound messages (encrypted blobs)
|
||||
3. Mule physically travels to destination
|
||||
4. Mule delivers blobs to destination server
|
||||
5. Mule carries back delivery receipts
|
||||
6. Receipt enforcement: no receipts = no new pickup
|
||||
|
||||
---
|
||||
|
||||
## Custom Client Development
|
||||
|
||||
### Using warzone-protocol as a Library
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
warzone-protocol = { path = "../warzone/crates/warzone-protocol" }
|
||||
```
|
||||
|
||||
Core operations:
|
||||
|
||||
```rust
|
||||
use warzone_protocol::identity::Seed;
|
||||
use warzone_protocol::prekey::{generate_signed_pre_key, generate_one_time_pre_keys};
|
||||
use warzone_protocol::x3dh;
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::message::WireMessage;
|
||||
|
||||
// Generate identity
|
||||
let seed = Seed::generate();
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
println!("Fingerprint: {}", pub_id.fingerprint);
|
||||
|
||||
// Generate pre-key bundle
|
||||
let (spk_secret, spk) = generate_signed_pre_key(&identity, 1);
|
||||
let otpks = generate_one_time_pre_keys(1, 10);
|
||||
|
||||
// Initiate session (Alice side)
|
||||
let x3dh_result = x3dh::initiate(&identity, &their_bundle)?;
|
||||
let mut ratchet = RatchetState::init_alice(
|
||||
x3dh_result.shared_secret,
|
||||
x25519_dalek::PublicKey::from(their_bundle.signed_pre_key.public_key),
|
||||
);
|
||||
|
||||
// Encrypt
|
||||
let encrypted = ratchet.encrypt(b"hello")?;
|
||||
|
||||
// Build wire message
|
||||
let wire = WireMessage::Message {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
sender_fingerprint: pub_id.fingerprint.to_string(),
|
||||
ratchet_message: encrypted,
|
||||
};
|
||||
let bytes = bincode::serialize(&wire)?;
|
||||
```
|
||||
|
||||
### WASM for Browsers
|
||||
|
||||
The `warzone-wasm` crate exposes the protocol to JavaScript:
|
||||
|
||||
```javascript
|
||||
import init, { WasmIdentity, WasmSession, decrypt_wire_message } from './warzone_wasm.js';
|
||||
|
||||
await init();
|
||||
|
||||
// Create identity
|
||||
const identity = new WasmIdentity();
|
||||
console.log("Fingerprint:", identity.fingerprint());
|
||||
console.log("Seed:", identity.seed_hex());
|
||||
|
||||
// Register bundle with server
|
||||
const bundleBytes = identity.bundle_bytes();
|
||||
await fetch('/v1/keys/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fingerprint: identity.fingerprint_hex(),
|
||||
bundle: Array.from(bundleBytes),
|
||||
}),
|
||||
});
|
||||
|
||||
// Create session and encrypt
|
||||
const session = WasmSession.initiate(identity, theirBundleBytes);
|
||||
const encrypted = session.encrypt_key_exchange(identity, theirBundleBytes, "hello");
|
||||
|
||||
// Decrypt incoming
|
||||
const result = decrypt_wire_message(
|
||||
identity.seed_hex(),
|
||||
identity.spk_secret_hex(),
|
||||
messageBytes,
|
||||
existingSessionBase64, // null for first message
|
||||
);
|
||||
const parsed = JSON.parse(result);
|
||||
// parsed.sender, parsed.text, parsed.session_data, parsed.message_id
|
||||
```
|
||||
|
||||
### Native Mobile (future)
|
||||
|
||||
The `warzone-protocol` crate compiles to any Rust target:
|
||||
|
||||
- **iOS:** via `cargo-lipo` or Swift package with C FFI
|
||||
- **Android:** via `cargo-ndk` with JNI bindings
|
||||
- Same crypto, same wire format, full interop
|
||||
|
||||
---
|
||||
|
||||
## Notification Integration (future)
|
||||
|
||||
### ntfy Concept
|
||||
|
||||
[ntfy](https://ntfy.sh) provides push notifications without Google Play Services:
|
||||
|
||||
```
|
||||
User registers topic: wz_<fingerprint_prefix>
|
||||
Server pushes on new message:
|
||||
POST https://ntfy.example.com/wz_a3f8c912
|
||||
Body: "New message" (NO content — E2E encrypted)
|
||||
User receives push → opens Warzone to read
|
||||
```
|
||||
|
||||
Self-hostable alongside the Warzone server. ntfy handles Android/iOS/desktop notifications.
|
||||
|
||||
### Metadata Consideration
|
||||
|
||||
ntfy sees that *someone* messaged a topic (user). Mitigation: self-host ntfy on the same infrastructure as the Warzone server.
|
||||
|
||||
---
|
||||
|
||||
## How to Add New Message Types
|
||||
|
||||
### Step 1: Extend WireMessage
|
||||
|
||||
In `warzone-protocol/src/message.rs`:
|
||||
|
||||
```rust
|
||||
pub enum WireMessage {
|
||||
// ... existing variants ...
|
||||
|
||||
/// Your new message type
|
||||
MyNewType {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
// your fields here
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
bincode serialization is automatic — the variant gets a new enum tag.
|
||||
|
||||
### Step 2: Update Server Dedup
|
||||
|
||||
In `warzone-server/src/routes/messages.rs` and `routes/ws.rs`, update `extract_message_id()`:
|
||||
|
||||
```rust
|
||||
WireMessage::MyNewType { id, .. } => Some(id),
|
||||
```
|
||||
|
||||
### Step 3: Handle in Clients
|
||||
|
||||
**TUI client** (`warzone-client/src/tui/app.rs`): Handle the new variant in the message receive/poll loop.
|
||||
|
||||
**Web client** (`warzone-wasm/src/lib.rs`): Add a match arm in `decrypt_wire_message()`:
|
||||
|
||||
```rust
|
||||
WireMessage::MyNewType { id, sender_fingerprint, .. } => {
|
||||
Ok(serde_json::json!({
|
||||
"type": "my_new_type",
|
||||
"id": id,
|
||||
"sender": sender_fingerprint,
|
||||
}).to_string())
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add Tests
|
||||
|
||||
In the protocol crate, add serialization and round-trip tests.
|
||||
|
||||
---
|
||||
|
||||
## How to Add New Commands
|
||||
|
||||
### TUI Commands
|
||||
|
||||
In `warzone-client/src/tui/app.rs`, inside `handle_send()`:
|
||||
|
||||
```rust
|
||||
if text.starts_with("/mycommand ") {
|
||||
let arg = text[11..].trim();
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("My command: {}", arg),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Pattern: parse the command text, perform the action, add a system message for feedback.
|
||||
|
||||
### Web Commands
|
||||
|
||||
In the web client JavaScript, add to the command dispatcher:
|
||||
|
||||
```javascript
|
||||
if (text.startsWith('/mycommand ')) {
|
||||
const arg = text.slice(11).trim();
|
||||
addSystemMessage(`My command: ${arg}`);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Add New Storage Backends
|
||||
|
||||
### Current Pattern
|
||||
|
||||
Both server (`db.rs`) and client (`storage.rs`) use sled directly with method wrappers:
|
||||
|
||||
```rust
|
||||
pub struct LocalDb {
|
||||
sessions: sled::Tree,
|
||||
// ...
|
||||
}
|
||||
|
||||
impl LocalDb {
|
||||
pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> {
|
||||
let data = bincode::serialize(state)?;
|
||||
self.sessions.insert(key, data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Abstracting to Traits (future)
|
||||
|
||||
```rust
|
||||
trait SessionStore {
|
||||
fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()>;
|
||||
fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>>;
|
||||
}
|
||||
|
||||
trait MessageStore {
|
||||
fn queue_message(&self, to: &str, message: &[u8]) -> Result<()>;
|
||||
fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>>;
|
||||
}
|
||||
|
||||
// Implementations:
|
||||
struct SledStore { /* ... */ }
|
||||
struct SqliteStore { /* ... */ }
|
||||
struct IndexedDbStore { /* ... */ } // for WASM
|
||||
```
|
||||
|
||||
The key insight: all storage is key-value with prefix scanning. Any ordered KV store (sled, RocksDB, SQLite, IndexedDB, LevelDB) can serve as a backend.
|
||||
268
warzone/docs/LLM_BOT_DEV.md
Normal file
268
warzone/docs/LLM_BOT_DEV.md
Normal 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
244
warzone/docs/LLM_HELP.md
Normal 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/*
|
||||
274
warzone/docs/PROGRESS.md
Normal file
274
warzone/docs/PROGRESS.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Warzone Messenger (featherChat) — Progress Report
|
||||
|
||||
**Current Version:** 0.0.21
|
||||
**Last Updated:** 2026-03-28
|
||||
|
||||
---
|
||||
|
||||
## Project Timeline
|
||||
|
||||
### Phase 0 — Python Prototype (pre-Rust)
|
||||
|
||||
The project began as `chat.py`, a Python WebSocket chat with basic features:
|
||||
|
||||
- Basic chat server + web UI
|
||||
- WebSocket SSH tunnel
|
||||
- Nginx reverse proxy + ArvanCloud deployment
|
||||
- ECDH + AES-GCM DMs (no forward secrecy)
|
||||
- Group chat with passwords
|
||||
- PWA support
|
||||
- File upload
|
||||
|
||||
### Phase 1 — Identity & Crypto Foundation (Rust Rewrite)
|
||||
|
||||
The Rust rewrite established the cryptographic foundation:
|
||||
|
||||
| Feature | Version | Status |
|
||||
|------------------------------------------|---------|--------|
|
||||
| Cargo workspace scaffold (5 crates) | 0.0.1 | Done |
|
||||
| Seed-based identity (Ed25519 + X25519) | 0.0.2 | Done |
|
||||
| BIP39 mnemonic generation and recovery | 0.0.2 | Done |
|
||||
| Seed encryption at rest (Argon2id + ChaCha20-Poly1305) | 0.0.3 | Done |
|
||||
| Pre-key bundle generation and storage | 0.0.4 | Done |
|
||||
| X3DH key exchange implementation | 0.0.5 | Done |
|
||||
| Double Ratchet for 1:1 messaging | 0.0.6 | Done |
|
||||
| Basic server: axum, sled DB, store-and-forward | 0.0.4 | Done |
|
||||
| CLI client with subcommands | 0.0.5 | Done |
|
||||
| WASM bridge (warzone-wasm crate) | 0.0.8 | Done |
|
||||
| Server auth (challenge-response, bearer tokens) | 0.0.9 | Done |
|
||||
| OTP key replenishment | 0.0.9 | Done |
|
||||
| Fetch-and-delete delivery | 0.0.7 | Done |
|
||||
| Aliases with TTL, recovery keys | 0.0.10 | Done |
|
||||
| 17 protocol tests | 0.0.10 | Done |
|
||||
| CLI ↔ Web interop verified | 0.0.10 | Done |
|
||||
|
||||
### Phase 2 — Core Messaging
|
||||
|
||||
Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
|
||||
| Feature | Version | Status |
|
||||
|------------------------------------------|---------|--------|
|
||||
| TUI client (ratatui + crossterm) | 0.0.7 | Done |
|
||||
| Web client (WASM) | 0.0.8 | Done |
|
||||
| WebSocket real-time push | 0.0.11 | Done |
|
||||
| Delivery receipts (sent/delivered/read) | 0.0.12 | Done |
|
||||
| File transfer (chunked, SHA-256 verified)| 0.0.13 | Done |
|
||||
| Group chat (server fan-out) | 0.0.10 | Done |
|
||||
| Group management (create/join/leave/kick)| 0.0.14 | Done |
|
||||
| Sender Keys for group encryption | 0.0.15 | Done |
|
||||
| Message deduplication (bounded FIFO) | 0.0.16 | Done |
|
||||
| Ethereum-compatible identity (secp256k1) | 0.0.14 | Done |
|
||||
| Encrypted backup/restore | 0.0.17 | Done |
|
||||
| Local message history (sled) | 0.0.17 | Done |
|
||||
| Contact list with message counts | 0.0.17 | Done |
|
||||
| Alias auto-renewal on activity | 0.0.18 | Done |
|
||||
| Multi-device key registration | 0.0.18 | Done |
|
||||
| DB lock handling with user-friendly errors | 0.0.19 | Done |
|
||||
| Readline-style TUI editing (Ctrl-A/E/U/W)| 0.0.19 | Done |
|
||||
| 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.21
|
||||
|
||||
### Codebase Statistics
|
||||
|
||||
| Metric | Value |
|
||||
|-------------------|--------------------------------|
|
||||
| Crates | 5 (protocol, server, client, wasm, mule) |
|
||||
| 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 |
|
||||
|
||||
### Protocol Crate Modules
|
||||
|
||||
| Module | Approximate Scope |
|
||||
|---------------|---------------------------------------|
|
||||
| identity | Seed, keypair derivation, fingerprints|
|
||||
| crypto | HKDF, ChaCha20-Poly1305 AEAD |
|
||||
| prekey | Signed + one-time pre-keys |
|
||||
| x3dh | Extended Triple Diffie-Hellman |
|
||||
| ratchet | Double Ratchet state machine |
|
||||
| message | WireMessage (8 variants incl. CallSignal)|
|
||||
| sender_keys | Sender Key encrypt/decrypt/rotate |
|
||||
| history | Encrypted backup format |
|
||||
| ethereum | secp256k1, Keccak-256, EIP-55 |
|
||||
| types | Fingerprint, DeviceId, SessionId |
|
||||
| mnemonic | BIP39 encode/decode |
|
||||
| store | Storage trait definitions |
|
||||
| errors | Error types |
|
||||
|
||||
### Feature Summary
|
||||
|
||||
**Working end-to-end:**
|
||||
- 1:1 encrypted DMs with forward secrecy (X3DH + Double Ratchet)
|
||||
- Group messaging with Sender Keys
|
||||
- WebSocket real-time delivery + offline queue
|
||||
- File transfer (up to 10 MB, chunked, SHA-256 verified)
|
||||
- Delivery and read receipts
|
||||
- TUI client with full command set
|
||||
- Web client (WASM) with identical crypto
|
||||
- Alias system with TTL, recovery, admin
|
||||
- Challenge-response authentication
|
||||
- Ethereum address derivation from same seed
|
||||
- Encrypted backup and restore
|
||||
- Contact list and message history
|
||||
- Multi-device support (basic)
|
||||
|
||||
---
|
||||
|
||||
## Test Suite
|
||||
|
||||
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 | 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 | 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 |
|
||||
|
||||
---
|
||||
|
||||
## Bugs Fixed
|
||||
|
||||
| Bug | Version Fixed | Description |
|
||||
|-----|---------------|-------------|
|
||||
| X3DH OTPK mismatch | 0.0.8 | Web client regenerated SPK on each page load, causing X3DH failures. Fixed by persisting SPK secret in localStorage and restoring on load. |
|
||||
| Axum route syntax | 0.0.11 | Route path parameters used wrong syntax for axum 0.7. Updated to `/:param` format. |
|
||||
| WASM SPK regeneration | 0.0.12 | WasmIdentity regenerated pre-keys on every `bundle_bytes()` call. Fixed by caching the bundle and storing SPK secret bytes. |
|
||||
| DB lock handling | 0.0.19 | sled database lock caused cryptic panic when another warzone process was running. Added user-friendly error message with recovery instructions. |
|
||||
| Dedup overflow | 0.0.16 | Dedup tracker grew unbounded. Fixed with FIFO eviction at 10,000 entries. |
|
||||
| Alias normalization | 0.0.18 | Fingerprints with colons caused lookup failures. Added `normalize_fp()` to strip non-hex characters. |
|
||||
| Receipt routing | 0.0.12 | Receipts sent to wrong fingerprint when switching peers in TUI. Fixed by including correct sender_fingerprint in Receipt wire messages. |
|
||||
|
||||
---
|
||||
|
||||
## Known Issues and Limitations
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **No perfect forward secrecy in groups:** Sender Keys provide forward secrecy within a chain but not per-message PFS like Double Ratchet. Acceptable for groups under 50 members.
|
||||
|
||||
2. **No sealed sender:** The server sees sender and recipient fingerprints in message routing metadata. Planned for Phase 6.
|
||||
|
||||
3. **No server-at-rest encryption:** The sled database on the server is unencrypted. Message content is E2E encrypted, but metadata (fingerprints, timestamps, group membership) is visible to the server operator.
|
||||
|
||||
4. **Auth tokens in memory:** Challenge-response tokens are partially stored in memory (challenges are in a static HashMap). Production deployment should use the DB for all auth state.
|
||||
|
||||
5. **No rate limiting:** No protection against message flooding or registration spam. Planned for Phase 7.
|
||||
|
||||
6. **Single server only:** No federation between servers yet. Planned for Phase 3.
|
||||
|
||||
7. **No push notifications:** Users must keep a WebSocket connection open or poll. ntfy integration planned for Phase 7.
|
||||
|
||||
8. **Web client: no OTPKs:** The web client does not generate one-time pre-keys (cannot reliably store secrets). X3DH works without DH4, but replay protection is slightly weaker.
|
||||
|
||||
9. **Web client: localStorage only:** Seed and session data stored in browser localStorage. Clearing browser data = lost identity.
|
||||
|
||||
10. **No message ordering guarantees:** Messages may arrive out of order. The Double Ratchet handles this for decryption, but the UI does not reorder displayed messages.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap: What's Next
|
||||
|
||||
### Phase 3 — Federation & Key Transparency (next priority)
|
||||
|
||||
- DNS TXT record format for server discovery
|
||||
- User self-signed key publication to DNS
|
||||
- Key verification: server vs DNS cross-check
|
||||
- Server-to-server mutual TLS
|
||||
- Federated message delivery
|
||||
- Server key pinning (TOFU)
|
||||
- Gossip-based peer discovery
|
||||
|
||||
### Phase 4 — Warzone Delivery
|
||||
|
||||
- Mule protocol specification and implementation
|
||||
- Mule authentication and authorization
|
||||
- Message pickup with capacity declaration
|
||||
- Delivery receipt enforcement
|
||||
- Outer encryption layer (hide metadata from mule)
|
||||
- Bundle compression (zstd)
|
||||
- Mule CLI binary
|
||||
|
||||
### Phase 5 — Transport Fallbacks
|
||||
|
||||
- Bluetooth mule transfer (phone-to-phone)
|
||||
- LoRa transport layer (compact binary format)
|
||||
- mDNS / LAN discovery for local mesh
|
||||
- Wi-Fi Direct for nearby device sync
|
||||
|
||||
### Phase 6 — Metadata Protection
|
||||
|
||||
- Sealed sender (server doesn't know the sender)
|
||||
- Onion routing between federated servers (opt-in)
|
||||
- Padding and traffic shaping
|
||||
- Traffic analysis resistance
|
||||
|
||||
### Phase 7 — Polish & Operations
|
||||
|
||||
- ntfy push notification integration
|
||||
- DNS-over-HTTPS for censored networks
|
||||
- Admin CLI for server management
|
||||
- Rate limiting and abuse prevention
|
||||
- Monitoring and health checks
|
||||
- Audit logging
|
||||
- Server-at-rest encryption (optional `--encrypt-db` flag)
|
||||
- Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
|
||||
- PWA: service worker, offline shell, install prompt
|
||||
|
||||
### Priority Order (Updated v0.0.21)
|
||||
|
||||
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.
|
||||
520
warzone/docs/PROTOCOL.md
Normal file
520
warzone/docs/PROTOCOL.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Warzone Protocol Specification
|
||||
|
||||
This document describes the cryptographic protocol used by Warzone messenger
|
||||
as currently implemented in the `warzone-protocol` crate.
|
||||
|
||||
---
|
||||
|
||||
## 1. Identity Model
|
||||
|
||||
### Seed-Based Identity
|
||||
|
||||
Every identity begins with a **seed**: 32 cryptographically random bytes
|
||||
generated from `OsRng`.
|
||||
|
||||
```
|
||||
seed (32 bytes, from OsRng)
|
||||
|
|
||||
+-- HKDF-SHA256(seed, info="warzone-ed25519") --> Ed25519 signing keypair
|
||||
|
|
||||
+-- HKDF-SHA256(seed, info="warzone-x25519") --> X25519 encryption keypair
|
||||
```
|
||||
|
||||
The seed is the single root secret. Both key derivations use HKDF with an
|
||||
empty salt and distinct `info` strings for domain separation.
|
||||
|
||||
### Key Types
|
||||
|
||||
| Key | Algorithm | Purpose |
|
||||
|-----|-----------|---------|
|
||||
| Signing keypair | Ed25519 (via `ed25519-dalek`) | Signs pre-keys, proves identity |
|
||||
| Encryption keypair | X25519 (via `x25519-dalek`) | Diffie-Hellman key exchange |
|
||||
|
||||
### Fingerprint
|
||||
|
||||
The fingerprint is the primary user identifier. It is computed as:
|
||||
|
||||
```
|
||||
fingerprint = SHA-256(Ed25519_public_key)[0..16] // first 16 bytes
|
||||
```
|
||||
|
||||
Displayed as four colon-separated groups of 4 hex digits (8 bytes / 64 bits
|
||||
of the 128-bit fingerprint):
|
||||
|
||||
```
|
||||
a3f8:c912:44be:7d01
|
||||
```
|
||||
|
||||
Note: the `Display` implementation uses only the first 8 bytes (4 groups of
|
||||
`u16`). The full 16 bytes are stored internally and used for session keying
|
||||
and lookups. The `from_hex` parser strips colons and decodes all 16 bytes.
|
||||
|
||||
### BIP39 Mnemonic
|
||||
|
||||
The 32-byte seed is presented to users as a 24-word BIP39 mnemonic for
|
||||
human-readable backup. Recovery works by converting the mnemonic back to 32
|
||||
bytes and re-deriving the same keypairs deterministically.
|
||||
|
||||
### PublicIdentity
|
||||
|
||||
The shareable portion of an identity:
|
||||
|
||||
```rust
|
||||
pub struct PublicIdentity {
|
||||
pub signing: VerifyingKey, // Ed25519 public key (32 bytes)
|
||||
pub encryption: PublicKey, // X25519 public key (32 bytes)
|
||||
pub fingerprint: Fingerprint, // SHA-256(signing)[0..16]
|
||||
}
|
||||
```
|
||||
|
||||
Serialized with serde; the dalek types use raw-bytes serialization.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pre-Key Bundles
|
||||
|
||||
Pre-key bundles enable asynchronous key exchange (the recipient does not need
|
||||
to be online when the sender initiates a session).
|
||||
|
||||
### Signed Pre-Key
|
||||
|
||||
A medium-term X25519 keypair signed by the identity Ed25519 key:
|
||||
|
||||
```rust
|
||||
pub struct SignedPreKey {
|
||||
pub id: u32,
|
||||
pub public_key: [u8; 32], // X25519 public key
|
||||
pub signature: Vec<u8>, // Ed25519 signature over public_key
|
||||
pub timestamp: i64, // unix timestamp of generation
|
||||
}
|
||||
```
|
||||
|
||||
The signature covers `public_key` directly (the raw 32 bytes). Verification
|
||||
uses the identity's Ed25519 verifying key.
|
||||
|
||||
### One-Time Pre-Key
|
||||
|
||||
A single-use X25519 keypair. Each key has a numeric `id`. After a key exchange
|
||||
consumes it, the private half is deleted.
|
||||
|
||||
```rust
|
||||
pub struct OneTimePreKeyPublic {
|
||||
pub id: u32,
|
||||
pub public_key: [u8; 32], // X25519 public key
|
||||
}
|
||||
```
|
||||
|
||||
### Bundle Format
|
||||
|
||||
The complete bundle uploaded to the server:
|
||||
|
||||
```rust
|
||||
pub struct PreKeyBundle {
|
||||
pub identity_key: [u8; 32], // Ed25519 verifying key
|
||||
pub identity_encryption_key: [u8; 32], // X25519 identity public key
|
||||
pub signed_pre_key: SignedPreKey,
|
||||
pub one_time_pre_key: Option<OneTimePreKeyPublic>,
|
||||
}
|
||||
```
|
||||
|
||||
Serialized with `bincode` for the wire and for local storage.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. `warzone init` generates 1 signed pre-key (id=1) and 10 one-time pre-keys
|
||||
(ids 0-9).
|
||||
2. Private halves are stored in the local sled database under the `pre_keys`
|
||||
tree (keys: `spk:<id>`, `otpk:<id>`).
|
||||
3. The public bundle is saved to `~/.warzone/bundle.bin`.
|
||||
4. On first `send` (or explicit `register`), the bundle is uploaded to the
|
||||
server.
|
||||
5. When a one-time pre-key is consumed during X3DH, it is atomically removed
|
||||
from local storage (`take_one_time_pre_key`).
|
||||
|
||||
**TODO (Phase 2):** automatic replenishment of one-time pre-keys when supply
|
||||
runs low; signed pre-key rotation on a schedule.
|
||||
|
||||
---
|
||||
|
||||
## 3. X3DH Key Exchange
|
||||
|
||||
The implementation follows Signal's Extended Triple Diffie-Hellman (X3DH)
|
||||
specification.
|
||||
|
||||
### Initiator (Alice)
|
||||
|
||||
Alice fetches Bob's `PreKeyBundle` from the server, then:
|
||||
|
||||
```
|
||||
1. Verify signed_pre_key.signature against identity_key
|
||||
2. Generate ephemeral X25519 keypair (ek)
|
||||
3. Compute four DH values:
|
||||
DH1 = X25519(Alice_identity_x25519, Bob_signed_pre_key)
|
||||
DH2 = X25519(Alice_ephemeral, Bob_identity_x25519)
|
||||
DH3 = X25519(Alice_ephemeral, Bob_signed_pre_key)
|
||||
DH4 = X25519(Alice_ephemeral, Bob_one_time_pre_key) [if present]
|
||||
4. Concatenate: DH1 || DH2 || DH3 [|| DH4]
|
||||
5. shared_secret = HKDF-SHA256(concat, salt="", info="warzone-x3dh", len=32)
|
||||
6. Zeroize the DH concatenation
|
||||
```
|
||||
|
||||
The result includes:
|
||||
- `shared_secret` (32 bytes) -- used to initialize the Double Ratchet
|
||||
- `ephemeral_public` -- sent to Bob
|
||||
- `used_one_time_pre_key_id` -- tells Bob which OT pre-key was consumed
|
||||
|
||||
### Responder (Bob)
|
||||
|
||||
Bob receives Alice's ephemeral public key plus her identity encryption key
|
||||
and computes the same DH operations in the mirror order:
|
||||
|
||||
```
|
||||
DH1 = X25519(Bob_signed_pre_key_secret, Alice_identity_x25519)
|
||||
DH2 = X25519(Bob_identity_x25519, Alice_ephemeral)
|
||||
DH3 = X25519(Bob_signed_pre_key_secret, Alice_ephemeral)
|
||||
DH4 = X25519(Bob_one_time_pre_key, Alice_ephemeral) [if used]
|
||||
```
|
||||
|
||||
The concatenation and HKDF produce the identical `shared_secret`.
|
||||
|
||||
### ASCII Diagram
|
||||
|
||||
```
|
||||
Alice Server Bob
|
||||
| | |
|
||||
|--- fetch Bob's bundle ------>| |
|
||||
|<-- PreKeyBundle -------------| |
|
||||
| | |
|
||||
| [verify SPK signature] | |
|
||||
| [generate ephemeral key] | |
|
||||
| [DH1..DH4 -> HKDF] | |
|
||||
| [init ratchet as Alice] | |
|
||||
| | |
|
||||
|--- WireMessage::KeyExchange -|---> queue for Bob |
|
||||
| (ephemeral_pub, otpk_id, | |
|
||||
| ratchet_message) | |
|
||||
| | |
|
||||
| | Bob polls ----------->|
|
||||
| |<-- WireMessage::KeyExchange |
|
||||
| | |
|
||||
| | [load SPK secret, OT secret]|
|
||||
| | [DH1..DH4 -> HKDF] |
|
||||
| | [init ratchet as Bob] |
|
||||
| | [decrypt first message] |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Double Ratchet
|
||||
|
||||
The Double Ratchet provides forward secrecy and break-in recovery. The
|
||||
implementation follows Signal's Double Ratchet specification.
|
||||
|
||||
### State
|
||||
|
||||
```rust
|
||||
pub struct RatchetState {
|
||||
dh_self: Vec<u8>, // our current X25519 secret (32 bytes)
|
||||
dh_remote: Option<[u8; 32]>, // their current DH public key
|
||||
root_key: [u8; 32], // root chain key
|
||||
chain_key_send: Option<[u8; 32]>, // sending chain key
|
||||
chain_key_recv: Option<[u8; 32]>, // receiving chain key
|
||||
send_count: u32, // messages sent in current sending chain
|
||||
recv_count: u32, // messages received in current receiving chain
|
||||
prev_send_count: u32, // messages in previous sending chain
|
||||
skipped: BTreeMap<([u8; 32], u32), [u8; 32]>, // cached keys for out-of-order messages
|
||||
}
|
||||
```
|
||||
|
||||
### Initialization
|
||||
|
||||
**Alice (initiator):**
|
||||
1. Receives `shared_secret` from X3DH and Bob's signed pre-key public as the
|
||||
initial remote ratchet key.
|
||||
2. Generates a fresh DH keypair.
|
||||
3. Performs `kdf_rk(shared_secret, DH(new_key, bob_spk))` to produce the
|
||||
first root key and sending chain key.
|
||||
4. No receiving chain yet (waits for Bob's first message).
|
||||
|
||||
**Bob (responder):**
|
||||
1. Receives the same `shared_secret` from X3DH.
|
||||
2. Uses his signed pre-key secret as the initial DH self key.
|
||||
3. Root key = shared_secret. No chain keys yet (waits for Alice's first
|
||||
message to trigger the first DH ratchet step).
|
||||
|
||||
### Sending a Message
|
||||
|
||||
```
|
||||
1. If no sending chain exists, perform a DH ratchet step first
|
||||
2. (new_chain_key, message_key) = kdf_ck(chain_key_send)
|
||||
3. chain_key_send = new_chain_key
|
||||
4. header = RatchetHeader { dh_public, prev_chain_length, message_number }
|
||||
5. aad = bincode::serialize(header)
|
||||
6. ciphertext = AEAD_encrypt(message_key, plaintext, aad)
|
||||
7. send_count += 1
|
||||
8. Return RatchetMessage { header, ciphertext }
|
||||
```
|
||||
|
||||
### Receiving a Message
|
||||
|
||||
```
|
||||
1. Check skipped message cache: if (dh_public, message_number) is cached,
|
||||
use that message key to decrypt and return
|
||||
2. If message.dh_public != dh_remote:
|
||||
a. Skip any missed messages in the current receiving chain
|
||||
b. DH ratchet step:
|
||||
- New receiving chain: kdf_rk(root_key, DH(our_secret, their_new_pub))
|
||||
- New sending chain: kdf_rk(root_key, DH(new_secret, their_new_pub))
|
||||
- Reset counters
|
||||
3. Skip messages up to message_number (cache skipped keys)
|
||||
4. (new_chain_key, message_key) = kdf_ck(chain_key_recv)
|
||||
5. aad = bincode::serialize(header)
|
||||
6. plaintext = AEAD_decrypt(message_key, ciphertext, aad)
|
||||
```
|
||||
|
||||
### Skipped Messages
|
||||
|
||||
When messages arrive out of order, the ratchet fast-forwards the receiving
|
||||
chain and caches the intermediate message keys in `skipped`. A maximum of
|
||||
`MAX_SKIP = 1000` messages can be skipped in one step to prevent resource
|
||||
exhaustion.
|
||||
|
||||
Cached keys are indexed by `(dh_public_key, message_number)` and are consumed
|
||||
(removed from the map) on first use.
|
||||
|
||||
### Message Header
|
||||
|
||||
```rust
|
||||
pub struct RatchetHeader {
|
||||
pub dh_public: [u8; 32], // sender's current DH ratchet public key
|
||||
pub prev_chain_length: u32, // messages in previous sending chain
|
||||
pub message_number: u32, // index in current sending chain
|
||||
}
|
||||
```
|
||||
|
||||
### DH Ratchet Diagram
|
||||
|
||||
```
|
||||
Alice Bob
|
||||
| |
|
||||
| send_chain_0 (from X3DH) |
|
||||
|------- msg 0 (dh_pub_A0) ---------------------->|
|
||||
|------- msg 1 (dh_pub_A0) ---------------------->|
|
||||
| |
|
||||
| recv: new dh_pub_A0 |
|
||||
| DH ratchet step |
|
||||
| send_chain_1 |
|
||||
|<------ msg 0 (dh_pub_B1) -----------------------|
|
||||
| |
|
||||
| recv: new dh_pub_B1 |
|
||||
| DH ratchet step |
|
||||
| send_chain_2 |
|
||||
|------- msg 0 (dh_pub_A2) ---------------------->|
|
||||
| |
|
||||
```
|
||||
|
||||
Each direction change triggers a DH ratchet step, producing new chain keys
|
||||
and providing forward secrecy and break-in recovery.
|
||||
|
||||
---
|
||||
|
||||
## 5. KDF Chains
|
||||
|
||||
All key derivation uses HKDF-SHA256 (via the `hkdf` crate with `sha2`).
|
||||
|
||||
### hkdf_derive
|
||||
|
||||
```rust
|
||||
fn hkdf_derive(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec<u8>
|
||||
```
|
||||
|
||||
- Empty salt is treated as `None` (HKDF uses a zero-filled salt internally).
|
||||
- `info` provides domain separation.
|
||||
|
||||
### Domain Separation Strings
|
||||
|
||||
| Context | info string | salt | Input |
|
||||
|---------|-------------|------|-------|
|
||||
| Ed25519 key from seed | `warzone-ed25519` | (empty) | seed |
|
||||
| X25519 key from seed | `warzone-x25519` | (empty) | seed |
|
||||
| X3DH shared secret | `warzone-x3dh` | (empty) | DH1\|\|DH2\|\|DH3[\|\|DH4] |
|
||||
| Root key ratchet | `warzone-ratchet-rk` | root_key | DH output |
|
||||
| Chain key -> message key | `warzone-ratchet-mk` | (empty) | chain_key |
|
||||
| Chain key -> next chain key | `warzone-ratchet-ck` | (empty) | chain_key |
|
||||
|
||||
### Root Key KDF (kdf_rk)
|
||||
|
||||
```
|
||||
derived = HKDF(ikm=dh_output, salt=root_key, info="warzone-ratchet-rk", len=64)
|
||||
new_root_key = derived[0..32]
|
||||
new_chain_key = derived[32..64]
|
||||
```
|
||||
|
||||
### Chain Key KDF (kdf_ck)
|
||||
|
||||
```
|
||||
message_key = HKDF(ikm=chain_key, salt="", info="warzone-ratchet-mk", len=32)
|
||||
new_chain_key = HKDF(ikm=chain_key, salt="", info="warzone-ratchet-ck", len=32)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. AEAD Encryption
|
||||
|
||||
All symmetric encryption uses **ChaCha20-Poly1305** (via the
|
||||
`chacha20poly1305` crate).
|
||||
|
||||
### Encrypt
|
||||
|
||||
```
|
||||
1. Generate 12-byte random nonce from OsRng
|
||||
2. ciphertext = ChaCha20-Poly1305(key, nonce, plaintext, aad)
|
||||
3. Output = nonce (12 bytes) || ciphertext (includes 16-byte Poly1305 tag)
|
||||
```
|
||||
|
||||
### Decrypt
|
||||
|
||||
```
|
||||
1. Split input: first 12 bytes = nonce, remainder = ciphertext+tag
|
||||
2. plaintext = ChaCha20-Poly1305_decrypt(key, nonce, ciphertext, aad)
|
||||
```
|
||||
|
||||
### Associated Data
|
||||
|
||||
For ratchet messages, the AAD is the `bincode`-serialized `RatchetHeader`.
|
||||
This binds the ciphertext to the specific ratchet position and prevents
|
||||
header manipulation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Wire Format
|
||||
|
||||
### WireMessage Enum
|
||||
|
||||
The top-level wire format is a `bincode`-serialized enum:
|
||||
|
||||
```rust
|
||||
pub enum WireMessage {
|
||||
KeyExchange {
|
||||
sender_fingerprint: String,
|
||||
sender_identity_encryption_key: [u8; 32],
|
||||
ephemeral_public: [u8; 32],
|
||||
used_one_time_pre_key_id: Option<u32>,
|
||||
ratchet_message: RatchetMessage,
|
||||
},
|
||||
Message {
|
||||
sender_fingerprint: String,
|
||||
ratchet_message: RatchetMessage,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**KeyExchange** is sent as the first message in a new session. It carries the
|
||||
X3DH parameters that the recipient needs to derive the shared secret and
|
||||
establish the ratchet.
|
||||
|
||||
**Message** is sent for all subsequent messages in an established session.
|
||||
|
||||
### RatchetMessage
|
||||
|
||||
```rust
|
||||
pub struct RatchetMessage {
|
||||
pub header: RatchetHeader, // DH public key, counters
|
||||
pub ciphertext: Vec<u8>, // nonce || ChaCha20-Poly1305 ciphertext
|
||||
}
|
||||
```
|
||||
|
||||
### WarzoneMessage (Defined But Not Yet Used on Wire)
|
||||
|
||||
The `message.rs` module defines a higher-level envelope:
|
||||
|
||||
```rust
|
||||
pub struct WarzoneMessage {
|
||||
pub version: u8,
|
||||
pub id: MessageId,
|
||||
pub from: Fingerprint,
|
||||
pub to: Fingerprint,
|
||||
pub timestamp: i64,
|
||||
pub msg_type: MessageType, // Text, File, KeyExchange, Receipt
|
||||
pub session_id: SessionId,
|
||||
pub ratchet_header: RatchetHeader,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub signature: Vec<u8>, // Ed25519 signature
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** this struct is defined but the current send/recv flow uses the
|
||||
simpler `WireMessage` enum directly. The `WarzoneMessage` envelope with
|
||||
signatures, message IDs, and session tracking will be integrated in Phase 2.
|
||||
|
||||
### MessageContent (Plaintext, Inside Envelope)
|
||||
|
||||
```rust
|
||||
pub enum MessageContent {
|
||||
Text { body: String },
|
||||
File { filename: String, data: Vec<u8> },
|
||||
Receipt { message_id: MessageId },
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** not yet used. Currently, raw UTF-8 bytes are encrypted directly.
|
||||
Structured content types will be used in Phase 2.
|
||||
|
||||
### Serialization
|
||||
|
||||
- **Wire (client <-> server):** `bincode` for `WireMessage` and
|
||||
`PreKeyBundle`. The server stores raw bincode blobs.
|
||||
- **Server API:** JSON for request/response wrappers. Binary payloads are
|
||||
base64-encoded within JSON.
|
||||
- **Local storage:** `bincode` for `RatchetState` and pre-key secrets in the
|
||||
sled database.
|
||||
|
||||
---
|
||||
|
||||
## 8. Transport
|
||||
|
||||
### Current Transport
|
||||
|
||||
HTTP POST/GET over TCP via `reqwest` (client) and `axum` (server). No TLS in
|
||||
the current implementation; TLS is expected to be provided by a reverse proxy.
|
||||
|
||||
Messages are delivered via polling: the client periodically GETs
|
||||
`/v1/messages/poll/{fingerprint}`.
|
||||
|
||||
### Future Transports (Phase 2+)
|
||||
|
||||
- WebSocket for real-time push
|
||||
- Server-to-server federation (Phase 3)
|
||||
- Bluetooth, LoRa, Wi-Fi Direct, USB sneakernet (Phase 4-5)
|
||||
|
||||
---
|
||||
|
||||
## 9. Security Properties
|
||||
|
||||
### What Is Achieved (Phase 1)
|
||||
|
||||
- **Confidentiality:** messages are encrypted with ChaCha20-Poly1305 using
|
||||
per-message keys derived from the Double Ratchet.
|
||||
- **Forward secrecy:** compromising the current ratchet state does not reveal
|
||||
past message keys (chain ratchet is one-way).
|
||||
- **Break-in recovery:** after a DH ratchet step, a compromised state becomes
|
||||
useless for future messages.
|
||||
- **Asynchronous key exchange:** X3DH allows session establishment without
|
||||
both parties being online simultaneously.
|
||||
- **Out-of-order tolerance:** skipped message keys are cached (up to 1000).
|
||||
- **Server learns nothing:** the server stores and forwards opaque bincode
|
||||
blobs. It never sees plaintext.
|
||||
|
||||
### What Is NOT Yet Implemented
|
||||
|
||||
- **Message signing:** `WarzoneMessage.signature` is defined but not populated.
|
||||
Currently, messages are not authenticated by Ed25519 signature. (Phase 2)
|
||||
- **Sealed sender:** the server can see sender and recipient fingerprints in
|
||||
the clear. (Phase 6)
|
||||
- **Key transparency:** no DNS-based verification of public keys. (Phase 3)
|
||||
- **Seed encryption at rest:** the seed file is stored as plaintext 32 bytes.
|
||||
Argon2 + ChaCha20-Poly1305 encryption is TODO.
|
||||
- **Pre-key replenishment:** one-time pre-keys are not automatically
|
||||
replenished after consumption.
|
||||
- **Message deduplication:** no dedup on the server or client.
|
||||
- **Group encryption:** Sender Keys not yet implemented. (Phase 2)
|
||||
471
warzone/docs/SECURITY.md
Normal file
471
warzone/docs/SECURITY.md
Normal file
@@ -0,0 +1,471 @@
|
||||
# Warzone Messenger (featherChat) — Security Model & Threat Analysis
|
||||
|
||||
**Version:** 0.0.21
|
||||
**Last Updated:** 2026-03-29
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
### What Is Protected
|
||||
|
||||
| Asset | Protection |
|
||||
|--------------------------|-----------------------------------------------|
|
||||
| Message content | E2E encrypted (ChaCha20-Poly1305 via Double Ratchet) |
|
||||
| Message integrity | AEAD authentication + Ed25519 signatures |
|
||||
| Past messages | Forward secrecy (Double Ratchet DH ratchet) |
|
||||
| Future messages | Future secrecy (recovery after DH ratchet step)|
|
||||
| Identity seed at rest | Argon2id + ChaCha20-Poly1305 passphrase encryption |
|
||||
| Identity portability | BIP39 mnemonic (24 words) |
|
||||
| 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)
|
||||
|
||||
| Asset | Exposure |
|
||||
|--------------------------|------------------------------------------------|
|
||||
| Sender/recipient metadata| Server sees who talks to whom |
|
||||
| Message timing | Server sees when messages are sent/received |
|
||||
| Group membership | Server stores group member lists in plaintext |
|
||||
| Alias ↔ fingerprint mapping | Server stores this mapping |
|
||||
| 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
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ TRUSTED: Client device │
|
||||
│ - Seed in memory (after unlock) │
|
||||
│ - Ratchet state │
|
||||
│ - Plaintext messages (in memory and local DB) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ SEMI-TRUSTED: Server │
|
||||
│ - Sees metadata (who, when, how much) │
|
||||
│ - Cannot read message content │
|
||||
│ - Cannot forge messages (no signing keys) │
|
||||
│ - Could drop, delay, or reorder messages │
|
||||
│ - Could serve wrong pre-key bundles (MITM) │
|
||||
│ → Mitigated by fingerprint verification │
|
||||
│ → Future: DNS key transparency │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ UNTRUSTED: Network │
|
||||
│ - All traffic encrypted (TLS + E2E) │
|
||||
│ - Passive observer sees TLS-encrypted WebSocket │
|
||||
│ - Active attacker cannot read or forge messages │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ UNTRUSTED: Other users │
|
||||
│ - Cannot read messages not addressed to them │
|
||||
│ - Cannot impersonate others (no access to seed) │
|
||||
│ - Trust established via TOFU or fingerprint verify │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
### Why Each Primitive Was Chosen
|
||||
|
||||
| Primitive | Used For | Why This One |
|
||||
|---------------------|-------------------------|-------------------------------------------------|
|
||||
| Ed25519 | Signing, identity | Fast, compact (32-byte keys), no side-channel concerns, widely audited. Deterministic signatures (no nonce reuse risk). |
|
||||
| X25519 | Key exchange (DH) | Paired with Ed25519, same curve family. Constant-time implementations. Used by Signal, WireGuard, Noise. |
|
||||
| ChaCha20-Poly1305 | AEAD encryption | Stream cipher — no padding oracle attacks. Faster than AES on platforms without AES-NI (ARM, WASM). Used by TLS 1.3, WireGuard, Signal. |
|
||||
| HKDF-SHA256 | Key derivation | Standard (RFC 5869). Clean domain separation via info strings. Used throughout Signal protocol. |
|
||||
| SHA-256 | Fingerprints | Ubiquitous, well-understood. 128-bit truncation for fingerprints provides sufficient collision resistance for this use case. |
|
||||
| Argon2id | Passphrase KDF | Winner of the Password Hashing Competition. Memory-hard (resists GPU/ASIC attacks). `id` variant provides resistance to both side-channel and GPU attacks. |
|
||||
| secp256k1 ECDSA | Ethereum compatibility | Required for Ethereum address derivation and wallet interop. Not used for messaging crypto. |
|
||||
| Keccak-256 | Ethereum addresses | Required by Ethereum spec. Only used for address derivation, not for messaging. |
|
||||
|
||||
### Crate Security
|
||||
|
||||
All crypto crates are widely audited Rust implementations:
|
||||
|
||||
| Crate | Notes |
|
||||
|-----------------------|------------------------------------------------|
|
||||
| `ed25519-dalek` 2.x | Maintained by dalek-cryptography team |
|
||||
| `x25519-dalek` 2.x | Same team, constant-time DH |
|
||||
| `chacha20poly1305` 0.10| RustCrypto project, audited |
|
||||
| `hkdf` 0.12 | RustCrypto project |
|
||||
| `sha2` 0.10 | RustCrypto project |
|
||||
| `argon2` 0.5 | RustCrypto project, Argon2id support |
|
||||
| `k256` 0.13 | RustCrypto secp256k1 implementation |
|
||||
| `rand` 0.8 | OS-provided randomness (OsRng) |
|
||||
| `zeroize` 1.x | Secure memory zeroing (Seed, keys) |
|
||||
|
||||
---
|
||||
|
||||
## Key Derivation Paths
|
||||
|
||||
All keys are derived from a single 32-byte seed using HKDF-SHA256 with distinct info strings for domain separation:
|
||||
|
||||
```
|
||||
Seed (32 bytes)
|
||||
│
|
||||
├─ HKDF(ikm=seed, salt="", info="warzone-ed25519") → Ed25519 signing key
|
||||
│
|
||||
├─ HKDF(ikm=seed, salt="", info="warzone-x25519") → X25519 encryption key
|
||||
│
|
||||
├─ HKDF(ikm=seed, salt="", info="warzone-secp256k1") → secp256k1 key (Ethereum)
|
||||
│
|
||||
└─ HKDF(ikm=seed, salt="", info="warzone-history") → History encryption key
|
||||
```
|
||||
|
||||
### X3DH Key Derivation
|
||||
|
||||
```
|
||||
DH1 = our_identity_x25519 * their_signed_pre_key
|
||||
DH2 = our_ephemeral * their_identity_x25519
|
||||
DH3 = our_ephemeral * their_signed_pre_key
|
||||
DH4 = our_ephemeral * their_one_time_pre_key (optional)
|
||||
|
||||
shared_secret = HKDF(ikm=DH1||DH2||DH3[||DH4], salt="", info="warzone-x3dh")
|
||||
```
|
||||
|
||||
### Double Ratchet Key Derivation
|
||||
|
||||
```
|
||||
Root KDF: HKDF(ikm=DH_output, salt=root_key, info="warzone-rk")
|
||||
→ (new_root_key, chain_key)
|
||||
|
||||
Chain KDF: HKDF(ikm=chain_key, salt="", info="warzone-ck")
|
||||
→ (new_chain_key, message_key)
|
||||
```
|
||||
|
||||
### Sender Key Derivation
|
||||
|
||||
```
|
||||
Message key: HKDF(ikm=chain_key, salt="", info="wz-sk-msg-{generation}-{counter}")
|
||||
Chain step: HKDF(ikm=chain_key, salt="", info="wz-sk-chain")
|
||||
```
|
||||
|
||||
### Seed Encryption
|
||||
|
||||
```
|
||||
salt = random(16 bytes)
|
||||
key = Argon2id(passphrase, salt) → 32 bytes
|
||||
nonce = random(12 bytes)
|
||||
ciphertext = ChaCha20-Poly1305(key, nonce, seed)
|
||||
|
||||
File: WZS1(4) || salt(16) || nonce(12) || ciphertext(48)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Forward Secrecy
|
||||
|
||||
### What Forward Secrecy Means
|
||||
|
||||
If an attacker compromises your current keys, they **cannot decrypt past messages**. Each message uses a unique key derived from the ratchet state, and old keys are deleted after use.
|
||||
|
||||
### How the Double Ratchet Provides It
|
||||
|
||||
```
|
||||
Message 1: chain_key_0 → message_key_0 (used, then deleted)
|
||||
Message 2: chain_key_1 → message_key_1 (used, then deleted)
|
||||
...
|
||||
DH Ratchet step: new DH exchange → new root_key → new chain
|
||||
Message N: chain_key_N → message_key_N (used, then deleted)
|
||||
```
|
||||
|
||||
**Symmetric ratchet:** Each message key is derived from the previous chain key, then the old chain key is overwritten. Compromise of the current chain key reveals future messages in this chain, but not past ones.
|
||||
|
||||
**DH ratchet:** Periodically, a new Diffie-Hellman exchange occurs (new ephemeral keypair). This "heals" from compromise — even if the current chain key is stolen, after the next DH ratchet step, the attacker is locked out again.
|
||||
|
||||
### Forward Secrecy Properties
|
||||
|
||||
| Scenario | Past Messages | Future Messages |
|
||||
|---------------------------------------|---------------|-----------------|
|
||||
| Current message key compromised | Safe | Safe |
|
||||
| Current chain key compromised | Safe | At risk until next DH ratchet |
|
||||
| DH private key compromised | Safe | At risk until next DH ratchet |
|
||||
| Seed compromised | At risk (all) | At risk (all) |
|
||||
|
||||
**Key insight:** Compromising the seed compromises everything because all keys derive from it. Protect the seed above all else.
|
||||
|
||||
### Skipped Message Keys
|
||||
|
||||
The Double Ratchet caches up to 1,000 skipped message keys to handle out-of-order delivery. These cached keys are a temporary window — they allow decryption of delayed messages but represent stored key material.
|
||||
|
||||
---
|
||||
|
||||
## Sender Keys: Trade-offs for Groups
|
||||
|
||||
### Efficiency vs Forward Secrecy
|
||||
|
||||
**Double Ratchet (1:1):** O(1) encrypt, O(1) decrypt. Perfect forward secrecy per message.
|
||||
|
||||
**Sender Keys (groups):** O(1) encrypt (one ciphertext for all members), O(1) decrypt per member. Forward secrecy per sender key chain, but NOT per-message.
|
||||
|
||||
### What Sender Keys Provide
|
||||
|
||||
- Forward ratcheting: each message advances the chain. Once a message key is derived and used, the chain key moves forward irreversibly.
|
||||
- Compromise of the current chain key reveals future messages in this generation, but not past messages.
|
||||
- Key rotation on member join/leave: all members generate new sender keys.
|
||||
|
||||
### What Sender Keys Do NOT Provide
|
||||
|
||||
- **No per-message forward secrecy:** Unlike Double Ratchet, there is no DH ratchet step per message. The symmetric chain ratchets forward, but a compromised chain key reveals all future messages until rotation.
|
||||
- **No future secrecy from member removal:** When a member is kicked, all remaining members must rotate their sender keys. If the kicked member cached the old chain key before rotation, they can read messages encrypted before the rotation completes.
|
||||
|
||||
### When This Matters
|
||||
|
||||
For groups under 50 members (the target), Sender Keys are a reasonable trade-off. For larger or higher-security groups, MLS (Message Layer Security, RFC 9420) would be more appropriate. MLS is not implemented and is not planned for the near term.
|
||||
|
||||
---
|
||||
|
||||
## Seed Security
|
||||
|
||||
### Encryption at Rest
|
||||
|
||||
The seed file uses defense-in-depth:
|
||||
|
||||
1. **Argon2id** with default parameters for memory-hard key derivation
|
||||
2. **ChaCha20-Poly1305** for authenticated encryption
|
||||
3. **Random salt** (16 bytes) prevents rainbow tables
|
||||
4. **Random nonce** (12 bytes) prevents nonce reuse
|
||||
5. **File permissions** set to 0600 on Unix
|
||||
|
||||
### Passphrase Strength
|
||||
|
||||
Argon2id makes brute-force expensive, but the passphrase is still the weak link. Users should choose strong passphrases. An empty passphrase stores the seed in plaintext (for testing only).
|
||||
|
||||
### Memory Safety
|
||||
|
||||
The `Seed` struct implements `Zeroize` and `ZeroizeOnDrop` from the `zeroize` crate. When the seed goes out of scope, its memory is securely overwritten with zeros.
|
||||
|
||||
### Hardware Wallet Concept (future)
|
||||
|
||||
The ideal: seed never leaves a hardware device. Ed25519/X25519 operations delegated to Ledger/Trezor. Session key delegation allows daily use without touching the hardware wallet for every message.
|
||||
|
||||
---
|
||||
|
||||
## Server Trust Model
|
||||
|
||||
### What the Server Can Do
|
||||
|
||||
- **See metadata:** sender fingerprint, recipient fingerprint, timestamp, message size
|
||||
- **Drop messages:** silently discard messages (denial of service)
|
||||
- **Delay messages:** hold messages before delivery
|
||||
- **Reorder messages:** deliver messages in a different order
|
||||
- **Serve wrong pre-key bundles:** MITM attack on key exchange (mitigated by fingerprint verification)
|
||||
- **See group membership:** group member lists are stored in plaintext
|
||||
- **See online status:** WebSocket connections reveal when users are online
|
||||
- **See alias mappings:** alias ↔ fingerprint relationships
|
||||
|
||||
### What the Server CANNOT Do
|
||||
|
||||
- **Read message content:** all messages are E2E encrypted
|
||||
- **Forge messages:** the server does not have users' signing keys
|
||||
- **Derive keys:** the server never has seeds, private keys, or session keys
|
||||
- **Decrypt past messages:** even if the server is later compromised, stored ciphertext remains unreadable
|
||||
- **Modify messages undetected:** AEAD authentication detects tampering
|
||||
|
||||
### MITM via Pre-Key Substitution
|
||||
|
||||
The most serious server attack: serve a malicious pre-key bundle (the server's own) instead of the recipient's real bundle. The sender would unknowingly encrypt to the server.
|
||||
|
||||
**Current mitigation:** Users verify fingerprints out-of-band. If the fingerprint matches, the pre-key bundle is authentic (because the signed pre-key is signed by the identity key corresponding to the fingerprint).
|
||||
|
||||
**Future mitigation:** DNS key transparency — users publish their public keys in DNS TXT records with self-signatures. The server cannot forge these records without the user's private key.
|
||||
|
||||
---
|
||||
|
||||
## Alias System Security
|
||||
|
||||
### TTL and Reclamation
|
||||
|
||||
- Aliases expire after **365 days** of inactivity
|
||||
- **30-day grace period** after expiry: only the recovery key holder can reclaim
|
||||
- After grace period: anyone can register the alias
|
||||
- Activity auto-renews: sending messages, WebSocket activity
|
||||
|
||||
This prevents:
|
||||
- **Squatting:** unused aliases are reclaimed
|
||||
- **Immediate takeover:** grace period gives the original owner time to recover
|
||||
|
||||
### Recovery Keys
|
||||
|
||||
- Generated server-side (16 random bytes, displayed as 32 hex chars)
|
||||
- Rotated on each recovery operation
|
||||
- Stored alongside the alias record
|
||||
- The only way to reclaim an alias after losing access to the associated fingerprint
|
||||
|
||||
### Admin Capabilities
|
||||
|
||||
The server admin (via `WARZONE_ADMIN_PASSWORD`) can:
|
||||
- Remove any alias (`/alias/admin-remove`)
|
||||
- Access is password-protected but not cryptographically authenticated
|
||||
|
||||
The admin **cannot**:
|
||||
- Read messages
|
||||
- Impersonate users
|
||||
- Modify pre-key bundles without detection
|
||||
|
||||
---
|
||||
|
||||
## WASM Security
|
||||
|
||||
### Same Crypto as Native
|
||||
|
||||
The web client uses the exact same Rust code compiled to WebAssembly:
|
||||
- `warzone-protocol` compiled via `wasm-pack`
|
||||
- X3DH, Double Ratchet, ChaCha20-Poly1305 — identical implementations
|
||||
- CLI-to-web and web-to-CLI messages are fully interoperable
|
||||
|
||||
### Randomness
|
||||
|
||||
WASM uses `OsRng` from the `rand` crate, which maps to `crypto.getRandomValues()` in the browser. This is a cryptographically secure random number generator provided by the Web Crypto API.
|
||||
|
||||
### Key Storage Limitations
|
||||
|
||||
| Concern | Native CLI | Web Client |
|
||||
|----------------------|-------------------------------|------------------------------|
|
||||
| Seed storage | Encrypted file (Argon2id) | localStorage (plaintext hex) |
|
||||
| Session persistence | sled DB on disk | localStorage (base64) |
|
||||
| Memory protection | Zeroize on drop | WASM linear memory (no zeroize guarantee) |
|
||||
| Process isolation | OS process isolation | Browser sandbox |
|
||||
| Side channels | Constant-time crypto | Same (WASM), but JS interop may leak timing |
|
||||
|
||||
**Key risk:** The web client stores the seed as plaintext hex in `localStorage`. Any XSS vulnerability could steal the seed. The native client encrypts the seed with a passphrase.
|
||||
|
||||
### No OTPKs in Web Client
|
||||
|
||||
The web client does not generate one-time pre-keys because `localStorage` cannot reliably store secrets that must be used exactly once. X3DH works without DH4 (the one-time pre-key DH). This means:
|
||||
- The key exchange still produces a shared secret from 3 DH operations
|
||||
- Anti-replay protection is slightly weaker (an attacker who captures the initial key exchange message could replay it if the server does not enforce OTP key deletion)
|
||||
- The server-side dedup tracker provides partial replay protection
|
||||
|
||||
---
|
||||
|
||||
## Known Weaknesses and Mitigations Planned
|
||||
|
||||
### 1. No Sealed Sender
|
||||
|
||||
**Weakness:** The server sees sender and recipient fingerprints.
|
||||
|
||||
**Mitigation (Phase 6):** Sealed sender — the server only sees the recipient, not the sender. The sender's identity is encrypted inside the E2E envelope.
|
||||
|
||||
### 2. No Traffic Analysis Protection
|
||||
|
||||
**Weakness:** Message timing and sizes reveal communication patterns.
|
||||
|
||||
**Mitigation (Phase 6):** Traffic padding and shaping. Optional onion routing between federated servers.
|
||||
|
||||
### 3. Web Client Seed Exposure
|
||||
|
||||
**Weakness:** Seed stored as plaintext hex in `localStorage`.
|
||||
|
||||
**Mitigation (planned):** Use Web Crypto API's `CryptoKey` with `non-extractable` flag. Derive encryption keys inside a Service Worker. Prompt for passphrase on load.
|
||||
|
||||
### 4. No Key Transparency
|
||||
|
||||
**Weakness:** Server could serve malicious pre-key bundles (MITM).
|
||||
|
||||
**Mitigation (Phase 3):** DNS TXT records with self-signed public keys. Cross-check server response against DNS.
|
||||
|
||||
### 5. Server Metadata Exposure
|
||||
|
||||
**Weakness:** Group membership, alias mappings, online status visible to server.
|
||||
|
||||
**Mitigation (partial, Phase 6):** Sealed sender hides sender identity. Group membership could be encrypted to group members only.
|
||||
|
||||
### 6. No Post-Quantum Crypto
|
||||
|
||||
**Weakness:** All key exchanges use classical DH (X25519). A future quantum computer could break stored ciphertext.
|
||||
|
||||
**Mitigation (future):** Hybrid key exchange (X25519 + ML-KEM/Kyber). The HKDF construction supports this naturally — concatenate classical and post-quantum shared secrets as HKDF input.
|
||||
|
||||
### 7. Auth Token Storage
|
||||
|
||||
**Weakness:** Challenge nonces stored in memory (not persisted). Server restart clears pending challenges.
|
||||
|
||||
**Mitigation (planned):** Store challenges in sled DB with TTL.
|
||||
|
||||
---
|
||||
|
||||
## Comparison with Other Messengers
|
||||
|
||||
### vs Signal
|
||||
|
||||
| Aspect | Warzone | Signal |
|
||||
|-----------------------|----------------------------------|----------------------------------|
|
||||
| Protocol | X3DH + Double Ratchet (same) | X3DH + Double Ratchet |
|
||||
| Groups | Sender Keys | Sender Keys (+ considering MLS) |
|
||||
| Identity | Seed-based (BIP39 mnemonic) | Phone number based |
|
||||
| Server | Self-hosted, open source | Centralized |
|
||||
| Federation | Planned (DNS-based) | No federation |
|
||||
| Offline delivery | Mule protocol (planned) | Push notification only |
|
||||
| Metadata protection | Not yet (planned) | Sealed sender, SGX enclaves |
|
||||
| Audit | Not audited | Extensively audited |
|
||||
| Mobile | Not yet (planned) | Native iOS/Android apps |
|
||||
| Phone requirement | No phone needed | Requires phone number |
|
||||
|
||||
### vs Matrix (Element)
|
||||
|
||||
| Aspect | Warzone | Matrix/Element |
|
||||
|-----------------------|----------------------------------|----------------------------------|
|
||||
| Protocol | Signal protocol (X3DH + DR) | Olm/Megolm (Double Ratchet variant) |
|
||||
| Groups | Sender Keys | Megolm (similar concept) |
|
||||
| Federation | Planned (DNS TXT) | Built-in (homeserver federation) |
|
||||
| Complexity | Minimal (5 crates) | Complex (homeserver, synapse) |
|
||||
| Encryption by default | Always E2E | Optional (not all rooms) |
|
||||
| Binary size | Single static binary | Python/Node server + Electron client |
|
||||
| Offline/mule | Designed for it | Not designed for offline |
|
||||
|
||||
### vs SimpleX
|
||||
|
||||
| Aspect | Warzone | SimpleX |
|
||||
|-----------------------|----------------------------------|----------------------------------|
|
||||
| Identity | Seed-based fingerprint | No user identity (contact-level) |
|
||||
| Metadata | Server sees sender/recipient | Server sees neither (relays) |
|
||||
| Groups | Sender Keys | Per-member encryption |
|
||||
| Federation | Planned (DNS) | Relay-based |
|
||||
| Offline delivery | Mule protocol (planned) | Queue-based |
|
||||
| Simplicity | Single binary, sled DB | Haskell server, complex setup |
|
||||
| Ethereum integration | Built-in | None |
|
||||
|
||||
### Key Differentiators
|
||||
|
||||
1. **Seed-based identity** with BIP39 mnemonic — no phone numbers, no accounts, portable across devices
|
||||
2. **Dual-curve identity** — same seed produces both messaging keys and Ethereum address
|
||||
3. **Designed for warzone conditions** — mule protocol, transport abstraction, offline-first
|
||||
4. **Single static binary** — no runtime dependencies, easy deployment
|
||||
5. **WASM web client** with identical crypto — no JS crypto, same Rust code
|
||||
6. **Self-hosted by design** — no dependency on centralized infrastructure
|
||||
634
warzone/docs/SERVER.md
Normal file
634
warzone/docs/SERVER.md
Normal file
@@ -0,0 +1,634 @@
|
||||
# Warzone Server -- Administration Guide
|
||||
|
||||
**Version 0.0.21**
|
||||
|
||||
---
|
||||
|
||||
## 1. Building
|
||||
|
||||
### Local Build
|
||||
|
||||
From the workspace root:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
cargo build -p warzone-server
|
||||
|
||||
# Release (recommended for deployment)
|
||||
cargo build -p warzone-server --release
|
||||
```
|
||||
|
||||
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 (`rust-version = "1.75"` in `Cargo.toml`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Running
|
||||
|
||||
### Basic
|
||||
|
||||
```bash
|
||||
# Defaults: bind 0.0.0.0:7700, data in ./warzone-data
|
||||
./warzone-server
|
||||
|
||||
# Custom bind address and data directory
|
||||
./warzone-server --bind 0.0.0.0:7700 --data-dir ./data
|
||||
|
||||
# With federation enabled
|
||||
./warzone-server --federation federation.json
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--bind` | `-b` | `0.0.0.0:7700` | Address and port to listen on |
|
||||
| `--data-dir` | `-d` | `./warzone-data` | Directory for the sled database |
|
||||
| `--federation` | `-f` | *(none)* | Path to federation JSON config file |
|
||||
| `--enable-bots` | | *(off)* | Enable Bot API and auto-create BotFather on startup |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| 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
|
||||
sudo cp deploy/warzone-server.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now warzone-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration
|
||||
|
||||
### Federation JSON
|
||||
|
||||
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.
|
||||
|
||||
**Format** (see `federation.example.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Federation
|
||||
|
||||
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`):
|
||||
|
||||
```json
|
||||
{
|
||||
"server_id": "alpha",
|
||||
"shared_secret": "s3cret-shared-between-both",
|
||||
"peer": { "id": "bravo", "url": "http://bravo-host:7700" },
|
||||
"presence_interval_secs": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Server B** (`bravo`, e.g. `kh3rad3ree`):
|
||||
|
||||
```json
|
||||
{
|
||||
"server_id": "bravo",
|
||||
"shared_secret": "s3cret-shared-between-both",
|
||||
"peer": { "id": "alpha", "url": "http://alpha-host:7700" },
|
||||
"presence_interval_secs": 5
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 4b. Bot System
|
||||
|
||||
### Enabling Bots
|
||||
|
||||
Start the server with `--enable-bots` to activate bot functionality. Without
|
||||
this flag, all bot endpoints return 403.
|
||||
|
||||
```bash
|
||||
./warzone-server --bind 0.0.0.0:7700 --enable-bots
|
||||
```
|
||||
|
||||
### BotFather Auto-Creation
|
||||
|
||||
On first start with `--enable-bots`, the server auto-creates the `@botfather`
|
||||
bot. The BotFather token is printed to the server logs. Users interact with
|
||||
`@botfather` to register new bots.
|
||||
|
||||
### Per-Instance Bot Toggle
|
||||
|
||||
Bot support can be enabled independently per server instance:
|
||||
|
||||
| Instance | Bots | Config |
|
||||
|----------|------|--------|
|
||||
| mequ | Disabled | No `--enable-bots` flag |
|
||||
| kh3rad3ree | Enabled | `--enable-bots` flag set |
|
||||
|
||||
### Bot Webhook Delivery
|
||||
|
||||
When a bot has a webhook configured (via `setWebhook`), incoming messages are
|
||||
delivered live to the webhook URL via HTTP POST instead of being queued for
|
||||
`getUpdates` polling. This is integrated into the standard message routing
|
||||
pipeline -- `deliver_or_queue` checks for webhook configuration before
|
||||
queueing.
|
||||
|
||||
---
|
||||
|
||||
## 5. API Reference
|
||||
|
||||
All endpoints are prefixed with `/v1`. The web UI is served at `/`.
|
||||
|
||||
### Notation
|
||||
|
||||
- **Auth** = requires `Authorization: Bearer <token>` header (write routes).
|
||||
- **Public** = no authentication needed (read routes).
|
||||
|
||||
---
|
||||
|
||||
### Health
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/health` | Public | Health check; returns `{"status":"ok"}` |
|
||||
|
||||
---
|
||||
|
||||
### Keys
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### Messages
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### Groups
|
||||
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
```
|
||||
warzone-data/
|
||||
db # sled database file
|
||||
conf # sled config
|
||||
blobs/ # sled blob storage
|
||||
snap.*/ # sled snapshots
|
||||
```
|
||||
|
||||
The entire directory should be treated as a unit for backup. Stop the server
|
||||
before copying, or use filesystem-level snapshots (LVM, ZFS, btrfs).
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
### Auth Middleware
|
||||
|
||||
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.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- **200 concurrent requests** (tower `ConcurrencyLimitLayer`)
|
||||
- **5 WebSocket connections per fingerprint** (multi-device cap)
|
||||
|
||||
### Device Management
|
||||
|
||||
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.
|
||||
|
||||
### What the Server Can See
|
||||
|
||||
| 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
|
||||
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
|
||||
```
|
||||
|
||||
With systemd:
|
||||
|
||||
```bash
|
||||
journalctl -u warzone-server -f
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:7700/v1/health
|
||||
```
|
||||
|
||||
### Federation Status
|
||||
|
||||
```bash
|
||||
curl http://localhost:7700/v1/federation/status
|
||||
```
|
||||
|
||||
Returns connection state, peer identity, and synced presence data.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deploy Scripts
|
||||
|
||||
The `scripts/build-linux.sh` script handles the full build and deploy
|
||||
lifecycle via Hetzner Cloud VMs.
|
||||
|
||||
### Key Commands
|
||||
|
||||
| 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) |
|
||||
|
||||
### Typical Deploy Workflow
|
||||
|
||||
```bash
|
||||
# One command: build, deploy everywhere, clean up
|
||||
./scripts/build-linux.sh --ship
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Backup and Recovery
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
systemctl stop warzone-server
|
||||
cp -r /home/warzone/data /backup/warzone-$(date +%Y%m%d)
|
||||
systemctl start warzone-server
|
||||
```
|
||||
|
||||
Do not copy the sled directory while the server is running without
|
||||
filesystem-level snapshots.
|
||||
|
||||
### Recovery
|
||||
|
||||
1. Stop the server.
|
||||
2. Replace the data directory with the backup.
|
||||
3. Start the server.
|
||||
|
||||
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
239
warzone/docs/TASK_PLAN.md
Normal 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
410
warzone/docs/TESTING_E2E.md
Normal 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 |
|
||||
456
warzone/docs/USAGE.md
Normal file
456
warzone/docs/USAGE.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# featherChat Usage Guide
|
||||
|
||||
**Version:** 0.0.21
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Build from Source
|
||||
|
||||
Requirements: Rust 1.75+, cargo
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd warzone
|
||||
|
||||
cargo build --release
|
||||
|
||||
# Binaries output to target/release/:
|
||||
# warzone-server — relay server (with embedded web client)
|
||||
# warzone-client — CLI / TUI client
|
||||
```
|
||||
|
||||
### WASM Build (Web Client)
|
||||
|
||||
Requirements: wasm-pack
|
||||
|
||||
```bash
|
||||
cd crates/warzone-wasm
|
||||
wasm-pack build --target web
|
||||
# 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
|
||||
|
||||
```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 |
|
||||
| `--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 |
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
### Generate a New Identity
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
### Recover from Mnemonic
|
||||
|
||||
```bash
|
||||
warzone-client recover abandon ability able about above absent absorb abstract ...
|
||||
# Prompts for passphrase, restores the same identity
|
||||
```
|
||||
|
||||
### View Your Identity
|
||||
|
||||
```bash
|
||||
warzone-client info
|
||||
# Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
```
|
||||
|
||||
In the TUI, use `/info` to display your fingerprint, `/seed` to display your 24-word recovery mnemonic, and `/eth` to display your Ethereum address.
|
||||
|
||||
### Ethereum Address
|
||||
|
||||
Your ETH address is derived from the same seed via domain-separated HKDF. One seed, dual identity.
|
||||
|
||||
```bash
|
||||
warzone-client eth
|
||||
# Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
# Ethereum: 0x71C7656EC7ab88b098defB751B7401B5f6d8976F
|
||||
```
|
||||
|
||||
### Addressing
|
||||
|
||||
featherChat supports three addressing modes. All three work anywhere a peer address is accepted:
|
||||
|
||||
| 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 Client
|
||||
|
||||
Launch the interactive terminal UI:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
#### Groups
|
||||
|
||||
| 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
|
||||
/gmembers
|
||||
/gkick @mallory
|
||||
/gleave
|
||||
/glist
|
||||
```
|
||||
|
||||
When in a group, the header bar shows `#groupname` and all messages are sent to that group.
|
||||
|
||||
#### Alias Management
|
||||
|
||||
| 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
#### Contacts and History
|
||||
|
||||
| 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 |
|
||||
|
||||
#### 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
|
||||
|
||||
| Indicator | Meaning |
|
||||
|-----------|---------|
|
||||
| Single tick | Sent (no confirmation yet) |
|
||||
| Double tick (gray) | Delivered (decrypted by recipient) |
|
||||
| Double tick (blue) | Read (viewed by recipient) |
|
||||
|
||||
---
|
||||
|
||||
## Web Client
|
||||
|
||||
### Access
|
||||
|
||||
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`.
|
||||
|
||||
The web client uses the same E2E encryption as the TUI, compiled to WASM.
|
||||
|
||||
### URL Deep Links
|
||||
|
||||
The web client supports deep links for direct navigation:
|
||||
|
||||
| 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` |
|
||||
|
||||
Share these links to let someone jump straight into a conversation.
|
||||
|
||||
### Clickable Addresses
|
||||
|
||||
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.
|
||||
|
||||
### Supported Commands
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## Voice Calls
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
### Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/call` | Start voice call with current peer |
|
||||
| `/accept` | Accept incoming call |
|
||||
| `/reject` | Reject incoming call |
|
||||
| `/hangup` | End current call |
|
||||
|
||||
---
|
||||
|
||||
## Groups
|
||||
|
||||
### Creating and Using Groups
|
||||
|
||||
```
|
||||
/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
|
||||
```
|
||||
|
||||
Once in a group, all messages you type go to that group. The server fans out to all members.
|
||||
|
||||
### Membership
|
||||
|
||||
- `/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
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## File Transfer
|
||||
|
||||
Files are transferred end-to-end encrypted through the relay server.
|
||||
|
||||
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.
|
||||
|
||||
Maximum file size: **10 MB**. Chunk size: **64 KB**.
|
||||
|
||||
---
|
||||
|
||||
## Friend List
|
||||
|
||||
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
|
||||
python tools/bot-bridge.py --token YOUR_BOT_TOKEN --server http://localhost:7700
|
||||
```
|
||||
|
||||
See `docs/BOT_API.md` and `docs/LLM_BOT_DEV.md` for full Bot API documentation.
|
||||
|
||||
---
|
||||
|
||||
## Federation
|
||||
|
||||
Federation connects two featherChat servers so that users on different servers can message each other transparently.
|
||||
|
||||
### Setup
|
||||
|
||||
Each server needs a federation config file:
|
||||
|
||||
```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.
|
||||
1209
warzone/docs/WZP_INTEGRATION.md
Normal file
1209
warzone/docs/WZP_INTEGRATION.md
Normal file
File diff suppressed because it is too large
Load Diff
9
warzone/federation.example.json
Normal file
9
warzone/federation.example.json
Normal 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
283
warzone/scripts/build-bleeding.sh
Executable 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
608
warzone/scripts/build-linux.sh
Executable 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
|
||||
7
warzone/test
Normal file
7
warzone/test
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
1. blush 2. cabbage 3. design 4. wrestle
|
||||
5. main 6. step 7. lunar 8. monitor
|
||||
9. hold 10. adult 11. silk 12. collect
|
||||
13. floor 14. list 15. trouble 16. gasp
|
||||
17. access 18. scorpion 19. rain 20. ring
|
||||
21. exchange 22. situate 23. cause 24. ribbon
|
||||
38
warzone/tools/README.md
Normal file
38
warzone/tools/README.md
Normal 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user