Compare commits
152 Commits
e364f437a2
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c248442c2 | ||
|
|
5ae87be316 | ||
|
|
f698b25fad | ||
|
|
7924871559 | ||
|
|
8a4f0ef8ee | ||
|
|
561f2d6978 | ||
|
|
da3cdd7234 | ||
|
|
cc76004655 | ||
|
|
9af5ec96b5 | ||
|
|
02471b28ba | ||
|
|
74af18463e | ||
|
|
b22200e3be | ||
|
|
850944944d | ||
|
|
47030a3b29 | ||
|
|
cac812665c | ||
|
|
f272a82faf | ||
|
|
11133cf968 | ||
|
|
8b00144b2f | ||
|
|
bf9594f1de | ||
|
|
366ab30988 | ||
|
|
fb29eb0fce | ||
|
|
33c39c6541 | ||
|
|
3d387e5821 | ||
|
|
38f992c284 | ||
|
|
59d68b2a5e | ||
|
|
f33ac1cad8 | ||
|
|
c2be68ca20 | ||
|
|
d7b75a6641 | ||
|
|
93923676a8 | ||
|
|
2612d46f5c | ||
|
|
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 |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
**/target
|
||||||
|
**/node_modules
|
||||||
|
**/.git
|
||||||
|
**/.DS_Store
|
||||||
|
**/.claude
|
||||||
|
**/wasm-pkg
|
||||||
|
apache
|
||||||
|
nginx
|
||||||
|
nginx.txt
|
||||||
|
chat.py
|
||||||
|
tunnel.py
|
||||||
|
DESIGN.md
|
||||||
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) |
|
| CLI | `~/.warzone/identity.seed` (encrypted with passphrase via Argon2 + ChaCha20) |
|
||||||
| Browser | IndexedDB (non-extractable CryptoKey) + seed backup prompt on first run |
|
| Browser | IndexedDB (non-extractable CryptoKey) + seed backup prompt on first run |
|
||||||
| Mobile (PWA) | Same as browser, seed shown as QR code for device transfer |
|
| 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
|
### Device Transfer
|
||||||
|
|
||||||
@@ -396,27 +408,63 @@ warzone.wasm # browser client (via wasm-pack)
|
|||||||
- [x] File upload
|
- [x] File upload
|
||||||
|
|
||||||
### Phase 1 — Identity & Crypto Foundation (Rust)
|
### Phase 1 — Identity & Crypto Foundation (Rust)
|
||||||
- [ ] Rust project scaffold (cargo workspace: server, client, protocol, mule)
|
- [x] Rust project scaffold (cargo workspace: server, client, protocol, mule, wasm)
|
||||||
- [ ] Seed-based identity (Ed25519 + X25519 from 32-byte seed)
|
- [x] Seed-based identity (Ed25519 + X25519 from 32-byte seed)
|
||||||
- [ ] BIP39 mnemonic generation and recovery
|
- [x] BIP39 mnemonic generation and recovery
|
||||||
- [ ] Seed encryption at rest (Argon2 + ChaCha20-Poly1305)
|
- [x] Seed encryption at rest (Argon2 + ChaCha20-Poly1305, unlock once per session)
|
||||||
- [ ] Pre-key bundle generation and storage
|
- [x] Pre-key bundle generation and storage
|
||||||
- [ ] X3DH key exchange implementation
|
- [x] X3DH key exchange implementation
|
||||||
- [ ] Double Ratchet for 1:1 messaging
|
- [x] Double Ratchet for 1:1 messaging (forward secrecy, out-of-order)
|
||||||
- [ ] Message signing (Ed25519)
|
- [x] Basic server: axum, sled DB, store-and-forward
|
||||||
- [ ] Basic server: accept connections, 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
|
### Phase 2 — Core Messaging
|
||||||
- [ ] 1:1 E2E encrypted messaging (full Signal protocol)
|
- [ ] WebSocket real-time push (replace HTTP polling with instant delivery)
|
||||||
- [ ] 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)
|
|
||||||
- [ ] Delivery receipts (sent, delivered, read)
|
- [ ] 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
|
- [ ] Message ordering and deduplication
|
||||||
- [ ] TUI client (ratatui)
|
- [ ] Ethereum-compatible identity (dual-curve: secp256k1 + X25519 from same BIP39 seed)
|
||||||
- [ ] Web client (WASM)
|
- 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
|
### Phase 3 — Federation & Key Transparency
|
||||||
- [ ] DNS TXT record format specification (server discovery + user 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 1d33f3ed4e
91
warzone/CLAUDE.md
Normal file
91
warzone/CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 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). Group calls are transport-encrypted only (QUIC/TLS); MLS (RFC 9420) E2E encryption for group calls is planned but not yet implemented.
|
||||||
|
|
||||||
|
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
|
||||||
|
- Ring tones use Web Audio API oscillators (no audio files) — see `startRingTone()`/`startRingbackTone()`/`stopRingTone()` in `web.rs`
|
||||||
|
|
||||||
|
### 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` |
|
||||||
|
| Group signal endpoint | `warzone-server/src/routes/groups.rs` (`signal_group`) |
|
||||||
|
| Ring tone functions | `warzone-server/src/routes/web.rs` (`startRingTone`, `startRingbackTone`, `stopRingTone`) |
|
||||||
|
| 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 = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -159,8 +160,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.24.0",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -188,6 +191,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -308,6 +317,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -507,6 +522,24 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -514,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -580,6 +613,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -597,6 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
@@ -612,6 +652,21 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "ed25519"
|
name = "ed25519"
|
||||||
version = "2.2.3"
|
version = "2.2.3"
|
||||||
@@ -631,7 +686,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"ed25519",
|
"ed25519",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -644,6 +699,26 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
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]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -675,6 +750,16 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
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]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -748,6 +833,17 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
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]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -767,6 +863,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
@@ -789,6 +887,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -798,8 +897,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"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]]
|
[[package]]
|
||||||
@@ -810,11 +925,22 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"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]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -972,6 +1098,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1252,6 +1379,21 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1312,6 +1454,12 @@ dependencies = [
|
|||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1503,7 +1651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1595,6 +1743,61 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -1604,6 +1807,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1617,8 +1826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1628,7 +1847,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1640,6 +1869,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatui"
|
name = "ratatui"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -1720,6 +1958,8 @@ dependencies = [
|
|||||||
"native-tls",
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1727,6 +1967,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-rustls",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
"tower-http 0.6.8",
|
"tower-http 0.6.8",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -1734,6 +1975,17 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
@@ -1750,6 +2002,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1792,6 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -1804,6 +2063,7 @@ version = "1.14.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1845,6 +2105,21 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -1940,6 +2215,27 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -2003,7 +2299,8 @@ version = "2.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_core",
|
"digest",
|
||||||
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2165,13 +2462,33 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -2194,6 +2511,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -2267,6 +2593,34 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -2286,6 +2640,10 @@ version = "0.4.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2422,6 +2780,45 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
@@ -2500,6 +2897,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -2553,30 +2956,40 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-client"
|
name = "warzone-client"
|
||||||
version = "0.1.0"
|
version = "0.0.47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"base64",
|
||||||
|
"bincode",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"rand",
|
"libc",
|
||||||
|
"rand 0.8.5",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sled",
|
"sled",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.24.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
"warzone-protocol",
|
"warzone-protocol",
|
||||||
|
"x25519-dalek",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-mule"
|
name = "warzone-mule"
|
||||||
version = "0.1.0"
|
version = "0.0.47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2585,7 +2998,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.1.0"
|
version = "0.0.47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -2596,11 +3009,13 @@ dependencies = [
|
|||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"rand",
|
"k256",
|
||||||
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
|
"tiny-keccak",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -2608,19 +3023,27 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-server"
|
name = "warzone-server"
|
||||||
version = "0.1.0"
|
version = "0.0.47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
|
"bincode",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"ed25519-dalek",
|
||||||
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sled",
|
"sled",
|
||||||
"thiserror",
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.21.0",
|
||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2629,6 +3052,26 @@ dependencies = [
|
|||||||
"warzone-protocol",
|
"warzone-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "warzone-wasm"
|
||||||
|
version = "0.0.47"
|
||||||
|
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]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -2756,6 +3199,25 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -3040,7 +3502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ members = [
|
|||||||
"crates/warzone-server",
|
"crates/warzone-server",
|
||||||
"crates/warzone-client",
|
"crates/warzone-client",
|
||||||
"crates/warzone-mule",
|
"crates/warzone-mule",
|
||||||
|
"crates/warzone-wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.0.47"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
@@ -24,6 +25,10 @@ sha2 = "0.10"
|
|||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|
||||||
|
# Ethereum compatibility
|
||||||
|
k256 = { version = "0.13", features = ["ecdsa", "serde"] }
|
||||||
|
tiny-keccak = { version = "2", features = ["keccak"] }
|
||||||
|
|
||||||
# BIP39
|
# BIP39
|
||||||
bip39 = "2"
|
bip39 = "2"
|
||||||
|
|
||||||
@@ -36,8 +41,8 @@ bincode = "1"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
axum = "0.7"
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
tower = "0.4"
|
tower = { version = "0.4", features = ["limit"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
|
|
||||||
# Client HTTP
|
# Client HTTP
|
||||||
@@ -73,5 +78,8 @@ base64 = "0.22"
|
|||||||
# UUID
|
# UUID
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# WebSocket client
|
||||||
|
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
|
||||||
|
|
||||||
# Zero secrets in memory
|
# Zero secrets in memory
|
||||||
zeroize = { version = "1", features = ["derive"] }
|
zeroize = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
190
warzone/README.md
Normal file
190
warzone/README.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# 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)
|
||||||
|
- **Voice Calls (WZP)** — DM and group calls via WarzonePhone audio bridge (QUIC SFU relay, ChaCha20-Poly1305 media)
|
||||||
|
- **Ring Tones** — Audible ring on incoming calls (web client)
|
||||||
|
- **Group Calls** — Multi-party audio via /gcall, /gjoin, /gleave-call, /gmute
|
||||||
|
- **Read Receipts** — Sent, delivered, and read indicators (viewport-based)
|
||||||
|
- **Markdown Rendering** — Bold, italic, inline code, headers, quotes, and lists in TUI and web
|
||||||
|
- **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted
|
||||||
|
- **Admin Commands** — /admin-calls, /admin-unalias for server administration
|
||||||
|
- **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
|
||||||
|
```
|
||||||
|
|
||||||
|
### WZP Setup (Voice Calls)
|
||||||
|
|
||||||
|
To enable voice calls, run a WarzonePhone relay alongside the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the WZP QUIC relay (default port 7701)
|
||||||
|
./target/release/wzp-relay --bind 0.0.0.0:7701
|
||||||
|
|
||||||
|
# Start the server with WZP integration
|
||||||
|
./target/release/warzone-server --bind 0.0.0.0:7700 --wzp-relay http://localhost:7701
|
||||||
|
```
|
||||||
|
|
||||||
|
DM calls use `/call @alias`, group calls use `/gcall` within a group context.
|
||||||
|
|
||||||
|
### 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 DM voice call |
|
||||||
|
| `/accept` / `/reject` | Accept or reject incoming call |
|
||||||
|
| `/hangup` | End current call |
|
||||||
|
| `/gcall` | Start group call in current group |
|
||||||
|
| `/gjoin` | Join active group call |
|
||||||
|
| `/gleave-call` | Leave group call |
|
||||||
|
| `/gmute` | Toggle mute in group 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
|
||||||
|
|
||||||
|
155 tests across protocol + client crates (all passing):
|
||||||
|
- Protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity, call signaling)
|
||||||
|
- TUI tests (rendering, keyboard input, scrolling, state management, call UI, markdown, receipts)
|
||||||
|
|
||||||
|
```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
|
rand.workspace = true
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
hex.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;
|
// Info is now handled directly in main.rs with the pre-unlocked identity.
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
use anyhow::Result;
|
||||||
use warzone_protocol::identity::Seed;
|
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::keystore;
|
||||||
|
use crate::net::ServerClient;
|
||||||
|
use crate::storage::LocalDb;
|
||||||
|
|
||||||
pub fn run() -> anyhow::Result<()> {
|
pub fn run() -> Result<()> {
|
||||||
let seed = Seed::generate();
|
let seed = Seed::generate();
|
||||||
let identity = seed.derive_identity();
|
let identity = seed.derive_identity();
|
||||||
let pub_id = identity.public_identity();
|
let pub_id = identity.public_identity();
|
||||||
@@ -21,7 +27,75 @@ pub fn run() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Save encrypted seed
|
// Save encrypted seed
|
||||||
keystore::save_seed(&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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
pub mod info;
|
pub mod info;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
pub mod recover;
|
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.
|
//! Seed storage: encrypted at rest with Argon2id + ChaCha20-Poly1305.
|
||||||
//! For Phase 1, we store the seed in plaintext. Encryption is TODO.
|
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::{self, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use argon2::Argon2;
|
||||||
|
use chacha20poly1305::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
ChaCha20Poly1305, Nonce,
|
||||||
|
};
|
||||||
|
use rand::RngCore;
|
||||||
use warzone_protocol::identity::Seed;
|
use warzone_protocol::identity::Seed;
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
fn seed_path() -> PathBuf {
|
/// Magic bytes to identify encrypted seed files.
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
const MAGIC: &[u8; 4] = b"WZS1";
|
||||||
PathBuf::from(home).join(".warzone").join("identity.seed")
|
/// 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<()> {
|
pub fn save_seed(seed: &Seed) -> anyhow::Result<()> {
|
||||||
let path = seed_path();
|
let path = seed_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
// TODO: encrypt with passphrase (Argon2 + ChaCha20-Poly1305)
|
|
||||||
fs::write(&path, &seed.0)?;
|
let passphrase = prompt_passphrase("Set passphrase (empty for no encryption): ");
|
||||||
// Set permissions to owner-only on Unix
|
|
||||||
|
if passphrase.is_empty() {
|
||||||
|
// Plaintext (legacy, for testing)
|
||||||
|
fs::write(&path, &seed.0)?;
|
||||||
|
} 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)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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> {
|
pub fn load_seed() -> anyhow::Result<Seed> {
|
||||||
let path = seed_path();
|
let path = seed_path();
|
||||||
let bytes = fs::read(&path)
|
let bytes = fs::read(&path)
|
||||||
.map_err(|_| anyhow::anyhow!("No identity found. Run `warzone init` first."))?;
|
.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)")
|
||||||
}
|
}
|
||||||
let mut seed_bytes = [0u8; 32];
|
|
||||||
seed_bytes.copy_from_slice(&bytes);
|
|
||||||
Ok(Seed::from_bytes(seed_bytes))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Generate a new identity (seed + keypair)
|
/// Generate a new identity (seed + keypair + pre-keys)
|
||||||
Init,
|
Init,
|
||||||
/// Recover identity from BIP39 mnemonic
|
/// Recover identity from BIP39 mnemonic
|
||||||
Recover {
|
Recover {
|
||||||
@@ -25,9 +25,17 @@ enum Commands {
|
|||||||
},
|
},
|
||||||
/// Show your fingerprint and public key
|
/// Show your fingerprint and public key
|
||||||
Info,
|
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 an encrypted message
|
||||||
Send {
|
Send {
|
||||||
/// Recipient fingerprint (e.g. a3f8:c912:44be:7d01)
|
/// Recipient fingerprint (e.g. a3f8:c912:...) or @alias
|
||||||
recipient: String,
|
recipient: String,
|
||||||
/// Message text
|
/// Message text
|
||||||
message: String,
|
message: String,
|
||||||
@@ -43,31 +51,94 @@ enum Commands {
|
|||||||
},
|
},
|
||||||
/// Launch interactive TUI chat
|
/// Launch interactive TUI chat
|
||||||
Chat {
|
Chat {
|
||||||
|
/// Peer fingerprint or @alias (optional)
|
||||||
|
peer: Option<String>,
|
||||||
/// Server URL
|
/// Server URL
|
||||||
#[arg(short, long, default_value = "http://localhost:7700")]
|
#[arg(short, long, default_value = "http://localhost:7700")]
|
||||||
server: String,
|
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();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Init => cli::init::run()?,
|
// These don't need an existing identity
|
||||||
Commands::Recover { words } => cli::recover::run(&words.join(" "))?,
|
Commands::Init => return cli::init::run(),
|
||||||
Commands::Info => cli::info::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 {
|
Commands::Send {
|
||||||
recipient,
|
recipient,
|
||||||
message,
|
message,
|
||||||
server,
|
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 } => {
|
Commands::Recv { server } => {
|
||||||
println!("TODO: poll messages from {}", server);
|
cli::recv::run(&server, &identity).await?;
|
||||||
}
|
}
|
||||||
Commands::Chat { server } => {
|
Commands::Chat { peer, server } => {
|
||||||
println!("TODO: launch TUI connected to {}", 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.
|
//! HTTP client for talking to warzone-server.
|
||||||
// TODO: implement in Phase 1 step 9.
|
|
||||||
|
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.
|
//! Local sled database: sessions, pre-keys, message history.
|
||||||
// TODO: implement in Phase 1 step 9.
|
|
||||||
|
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.
|
mod types;
|
||||||
// TODO: implement in Phase 1 step 10.
|
mod draw;
|
||||||
pub mod app;
|
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]
|
[package]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version.workspace = true
|
version = "0.0.47"
|
||||||
edition.workspace = true
|
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]
|
[dependencies]
|
||||||
ed25519-dalek.workspace = true
|
# Crypto
|
||||||
x25519-dalek.workspace = true
|
ed25519-dalek = { version = "2", features = ["serde", "rand_core"] }
|
||||||
curve25519-dalek.workspace = true
|
x25519-dalek = { version = "2", features = ["serde", "static_secrets"] }
|
||||||
chacha20poly1305.workspace = true
|
curve25519-dalek = "4"
|
||||||
hkdf.workspace = true
|
chacha20poly1305 = "0.10"
|
||||||
sha2.workspace = true
|
hkdf = "0.12"
|
||||||
rand.workspace = true
|
sha2 = "0.10"
|
||||||
bip39.workspace = true
|
rand = "0.8"
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
# Ethereum compatibility
|
||||||
bincode.workspace = true
|
k256 = { version = "0.13", features = ["ecdsa", "serde"] }
|
||||||
thiserror.workspace = true
|
tiny-keccak = { version = "2", features = ["keccak"] }
|
||||||
hex.workspace = true
|
|
||||||
base64.workspace = true
|
# BIP39
|
||||||
uuid.workspace = true
|
bip39 = "2"
|
||||||
zeroize.workspace = true
|
|
||||||
chrono.workspace = true
|
# 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 id = seed.derive_identity();
|
||||||
let pub_id = id.public_identity();
|
let pub_id = id.public_identity();
|
||||||
let fp_str = pub_id.fingerprint.to_string();
|
let fp_str = pub_id.fingerprint.to_string();
|
||||||
// Format: xxxx:xxxx:xxxx:xxxx
|
// Format: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
|
||||||
assert_eq!(fp_str.len(), 19);
|
assert_eq!(fp_str.len(), 39);
|
||||||
assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 3);
|
assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,7 @@ pub mod ratchet;
|
|||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod store;
|
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> },
|
File { filename: String, data: Vec<u8> },
|
||||||
Receipt { message_id: MessageId },
|
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;
|
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.
|
/// A message produced by the ratchet.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct RatchetMessage {
|
pub struct RatchetMessage {
|
||||||
pub header: RatchetHeader,
|
pub header: RatchetHeader,
|
||||||
pub ciphertext: Vec<u8>,
|
pub ciphertext: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header included with each ratchet message.
|
/// Header included with each ratchet message.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct RatchetHeader {
|
pub struct RatchetHeader {
|
||||||
/// Current DH ratchet public key.
|
/// Current DH ratchet public key.
|
||||||
pub dh_public: [u8; 32],
|
pub dh_public: [u8; 32],
|
||||||
@@ -208,6 +213,37 @@ impl RatchetState {
|
|||||||
Ok(())
|
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> {
|
fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> {
|
||||||
let their_pub = self
|
let their_pub = self
|
||||||
.dh_remote
|
.dh_remote
|
||||||
@@ -312,6 +348,35 @@ mod tests {
|
|||||||
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
|
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]
|
#[test]
|
||||||
fn many_messages() {
|
fn many_messages() {
|
||||||
let (mut alice, mut bob) = make_pair();
|
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 {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(
|
write!(
|
||||||
f,
|
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[0], self.0[1]]),
|
||||||
u16::from_be_bytes([self.0[2], self.0[3]]),
|
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[4], self.0[5]]),
|
||||||
u16::from_be_bytes([self.0[6], self.0[7]]),
|
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);
|
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
|
chrono.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
base64.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 {
|
// Server configuration — currently handled via CLI args in main.rs.
|
||||||
pub bind_addr: String,
|
// This module will be used when file-based configuration is added.
|
||||||
pub data_dir: String,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ use anyhow::Result;
|
|||||||
pub struct Database {
|
pub struct Database {
|
||||||
pub keys: sled::Tree,
|
pub keys: sled::Tree,
|
||||||
pub messages: 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,
|
_db: sled::Db,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,11 +18,23 @@ impl Database {
|
|||||||
let db = sled::open(data_dir)?;
|
let db = sled::open(data_dir)?;
|
||||||
let keys = db.open_tree("keys")?;
|
let keys = db.open_tree("keys")?;
|
||||||
let messages = db.open_tree("messages")?;
|
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 {
|
Ok(Database {
|
||||||
keys,
|
keys,
|
||||||
messages,
|
messages,
|
||||||
otpks,
|
groups,
|
||||||
|
aliases,
|
||||||
|
tokens,
|
||||||
|
calls,
|
||||||
|
missed_calls,
|
||||||
|
friends,
|
||||||
|
eth_addresses,
|
||||||
_db: db,
|
_db: db,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
|
/// Wraps anyhow::Error into an axum-compatible error response.
|
||||||
pub struct AppError(pub anyhow::Error);
|
pub struct AppError(pub anyhow::Error);
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
|
tracing::error!("{:#}", self.0);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
|
(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())
|
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 config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
|
pub mod federation;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
mod botfather;
|
||||||
|
pub mod auth_middleware;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod errors;
|
mod errors;
|
||||||
|
mod federation;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
@@ -16,25 +19,236 @@ struct Cli {
|
|||||||
/// Database directory
|
/// Database directory
|
||||||
#[arg(short, long, default_value = "./warzone-data")]
|
#[arg(short, long, default_value = "./warzone-data")]
|
||||||
data_dir: String,
|
data_dir: String,
|
||||||
|
|
||||||
|
/// Federation config file (JSON). Enables server-to-server message relay.
|
||||||
|
#[arg(short, long)]
|
||||||
|
federation: Option<String>,
|
||||||
|
|
||||||
|
/// 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]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
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();
|
let cli = Cli::parse();
|
||||||
tracing::info!("Warzone server starting on {}", cli.bind);
|
tracing::info!("Warzone server starting on {}", cli.bind);
|
||||||
|
|
||||||
let state = state::AppState::new(&cli.data_dir)?;
|
let mut state = state::AppState::new(&cli.data_dir)?;
|
||||||
|
|
||||||
|
// 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()
|
let app = axum::Router::new()
|
||||||
.merge(routes::web_router())
|
.merge(routes::web_router())
|
||||||
.nest("/v1", routes::router())
|
.nest("/v1", routes::router())
|
||||||
|
.layer(cors)
|
||||||
|
.layer(tower::limit::ConcurrencyLimitLayer::new(200))
|
||||||
|
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
|
let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
|
||||||
tracing::info!("Listening on {}", cli.bind);
|
tracing::info!("Listening on {}", cli.bind);
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
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 })))
|
||||||
|
}
|
||||||
352
warzone/crates/warzone-server/src/routes/groups.rs
Normal file
352
warzone/crates/warzone-server/src/routes/groups.rs
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
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))
|
||||||
|
.route("/groups/:name/signal", post(signal_group))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a plaintext signal to all online group members via WS push.
|
||||||
|
/// Used for group calls, typing indicators, etc. — NOT for encrypted messages.
|
||||||
|
async fn signal_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Json(req): Json<serde_json::Value>,
|
||||||
|
) -> 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 = req
|
||||||
|
.get("from")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let from = normalize_fp(&from);
|
||||||
|
if !group.members.contains(&from) {
|
||||||
|
return Ok(Json(serde_json::json!({ "error": "not a member" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast the raw JSON payload to all online members except sender
|
||||||
|
let payload = serde_json::to_vec(&req).unwrap_or_default();
|
||||||
|
let mut pushed = 0;
|
||||||
|
for member in &group.members {
|
||||||
|
if *member == from {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if state.push_to_client(member, &payload).await {
|
||||||
|
pushed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Group '{}' signal from {}: pushed to {}/{} members",
|
||||||
|
name,
|
||||||
|
from,
|
||||||
|
pushed,
|
||||||
|
group.members.len() - 1
|
||||||
|
);
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "pushed": pushed })))
|
||||||
|
}
|
||||||
@@ -1,12 +1,66 @@
|
|||||||
use axum::{routing::get, Json, Router};
|
use axum::{extract::ConnectInfo, http::HeaderMap, routing::get, Json, Router};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new().route("/health", get(health))
|
Router::new()
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/whoami", get(whoami))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> Json<serde_json::Value> {
|
async fn health() -> Json<serde_json::Value> {
|
||||||
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
|
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn whoami(
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
// Prefer X-Forwarded-For (set by Caddy/reverse proxy), then X-Real-Ip, then direct
|
||||||
|
let forwarded = headers
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|v| v.split(',').next().unwrap_or("").trim().to_string());
|
||||||
|
|
||||||
|
let real_ip = headers
|
||||||
|
.get("x-real-ip")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let direct = connect_info.map(|ci| ci.0.ip().to_string());
|
||||||
|
|
||||||
|
let ip = forwarded.clone()
|
||||||
|
.or(real_ip.clone())
|
||||||
|
.or(direct.clone())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Classify as IPv4 or IPv6
|
||||||
|
let is_v6 = ip.contains(':');
|
||||||
|
|
||||||
|
let via = headers.get("via").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
|
||||||
|
let proto = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
|
||||||
|
let host = headers.get("x-forwarded-host").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
|
||||||
|
let behind_proxy = forwarded.is_some() || real_ip.is_some() || via.is_some();
|
||||||
|
|
||||||
|
let mut result = json!({
|
||||||
|
"ip": ip,
|
||||||
|
"version": if is_v6 { "IPv6" } else { "IPv4" },
|
||||||
|
"direct": direct,
|
||||||
|
"behind_proxy": behind_proxy,
|
||||||
|
});
|
||||||
|
|
||||||
|
if behind_proxy {
|
||||||
|
let proxy = json!({
|
||||||
|
"x_forwarded_for": forwarded,
|
||||||
|
"x_real_ip": real_ip,
|
||||||
|
"x_forwarded_proto": proto,
|
||||||
|
"x_forwarded_host": host,
|
||||||
|
"via": via,
|
||||||
|
});
|
||||||
|
result.as_object_mut().unwrap().insert("proxy".to_string(), proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,13 +10,44 @@ use crate::state::AppState;
|
|||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/keys/register", post(register_keys))
|
.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)]
|
#[derive(Deserialize)]
|
||||||
struct RegisterRequest {
|
struct RegisterRequest {
|
||||||
fingerprint: String,
|
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)]
|
#[derive(Serialize)]
|
||||||
@@ -25,10 +56,31 @@ struct RegisterResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn register_keys(
|
async fn register_keys(
|
||||||
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> Json<RegisterResponse> {
|
) -> 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 })
|
Json(RegisterResponse { ok: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +88,112 @@ async fn get_bundle(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(fingerprint): Path<String>,
|
Path(fingerprint): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
|
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
|
||||||
match state.db.keys.get(fingerprint.as_bytes()) {
|
let key = normalize_fp(&fingerprint);
|
||||||
Ok(Some(data)) => Ok(Json(serde_json::json!({
|
tracing::info!("get_bundle: raw path='{}', normalized='{}'", fingerprint, key);
|
||||||
"fingerprint": fingerprint,
|
|
||||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
|
// Debug: list what's in the DB
|
||||||
}))),
|
let all_keys: Vec<String> = state.db.keys.iter()
|
||||||
_ => Err(axum::http::StatusCode::NOT_FOUND),
|
.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),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
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,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use warzone_protocol::message::WireMessage;
|
||||||
|
|
||||||
|
use crate::errors::AppResult;
|
||||||
use crate::state::AppState;
|
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> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/messages/send", post(send_message))
|
.route("/messages/send", post(send_message))
|
||||||
.route("/messages/poll/{fingerprint}", get(poll_messages))
|
.route("/messages/poll/:fingerprint", get(poll_messages))
|
||||||
.route("/messages/{id}/ack", delete(ack_message))
|
.route("/messages/:id/ack", delete(ack_message))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SendRequest {
|
struct SendRequest {
|
||||||
to: String,
|
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(
|
async fn send_message(
|
||||||
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<SendRequest>,
|
Json(req): Json<SendRequest>,
|
||||||
) -> Json<serde_json::Value> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
// Append to recipient's queue
|
let to = normalize_fp(&req.to);
|
||||||
let key = format!("queue:{}", req.to);
|
|
||||||
let _ = state.db.messages.insert(
|
// Dedup: if we have already seen this message ID, silently drop it
|
||||||
format!("{}:{}", key, uuid::Uuid::new_v4()).as_bytes(),
|
if let Some(msg_id) = extract_message_id(&req.message) {
|
||||||
req.message,
|
if state.dedup.check_and_insert(&msg_id) {
|
||||||
);
|
tracing::debug!("Dedup: dropping duplicate message {}", msg_id);
|
||||||
Json(serde_json::json!({ "ok": true }))
|
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(
|
async fn poll_messages(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(fingerprint): Path<String>,
|
Path(fingerprint): Path<String>,
|
||||||
) -> Json<Vec<String>> {
|
) -> AppResult<Json<Vec<String>>> {
|
||||||
let prefix = format!("queue:{}", fingerprint);
|
let prefix = format!("queue:{}", normalize_fp(&fingerprint));
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
|
let mut keys_to_delete = Vec::new();
|
||||||
|
|
||||||
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
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(
|
messages.push(base64::Engine::encode(
|
||||||
&base64::engine::general_purpose::STANDARD,
|
&base64::engine::general_purpose::STANDARD,
|
||||||
&value,
|
&value,
|
||||||
));
|
));
|
||||||
}
|
keys_to_delete.push(key);
|
||||||
}
|
}
|
||||||
Json(messages)
|
|
||||||
|
// Delete after collecting (fetch-and-delete)
|
||||||
|
for key in &keys_to_delete {
|
||||||
|
state.db.messages.remove(key)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
async fn ack_message(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Json<serde_json::Value> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
// Scan for and remove the message with this ID
|
state.db.messages.remove(id.as_bytes())?;
|
||||||
// In a real implementation, we'd have a proper index
|
Ok(Json(serde_json::json!({ "ok": true })))
|
||||||
let _ = state.db.messages.remove(id.as_bytes());
|
|
||||||
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 health;
|
||||||
mod keys;
|
mod keys;
|
||||||
mod messages;
|
pub mod messages;
|
||||||
|
mod presence;
|
||||||
|
mod resolve;
|
||||||
mod web;
|
mod web;
|
||||||
|
mod ws;
|
||||||
|
mod wzp;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
@@ -12,6 +24,18 @@ pub fn router() -> Router<AppState> {
|
|||||||
.merge(health::routes())
|
.merge(health::routes())
|
||||||
.merge(keys::routes())
|
.merge(keys::routes())
|
||||||
.merge(messages::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)
|
/// 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 std::sync::Arc;
|
||||||
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
|
||||||
|
/// Maximum WebSocket connections per fingerprint (multi-device cap).
|
||||||
|
const MAX_WS_PER_FINGERPRINT: usize = 5;
|
||||||
|
|
||||||
|
/// Maximum number of message IDs to track for deduplication.
|
||||||
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Arc<Database>,
|
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 {
|
impl AppState {
|
||||||
pub fn new(data_dir: &str) -> anyhow::Result<Self> {
|
pub fn new(data_dir: &str) -> anyhow::Result<Self> {
|
||||||
let db = Database::open(data_dir)?;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
warzone/deploy/docker/.env.example
Normal file
12
warzone/deploy/docker/.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Copy to .env and fill in values
|
||||||
|
|
||||||
|
# Cloudflare API token (Zone:DNS:Edit permission for manko.yoga)
|
||||||
|
# Also create cf_api_token.txt with the same token for Docker secrets
|
||||||
|
# echo "YOUR_TOKEN" > cf_api_token.txt
|
||||||
|
CF_API_TOKEN=
|
||||||
|
|
||||||
|
# DNS records to create:
|
||||||
|
# voip.manko.yoga → A 172.16.81.135 (dev)
|
||||||
|
# voip.manko.yoga → AAAA 2a0d:3344:692c:2500:14f2:5885:d73c:b0a1 (ipv6 test)
|
||||||
|
# voip.manko.yoga → A 63.250.54.239 (production)
|
||||||
|
# voip.manko.yoga → AAAA 2602:ff16:9:0:1:3d9:0:1 (production ipv6)
|
||||||
30
warzone/deploy/docker/Caddyfile
Normal file
30
warzone/deploy/docker/Caddyfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
email admin@manko.yoga
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wildcard cert for all subdomains
|
||||||
|
*.voip.manko.yoga {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {$CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
reverse_proxy wzp-web:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main domain — featherChat server
|
||||||
|
voip.manko.yoga {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {$CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /audio/* {
|
||||||
|
reverse_proxy wzp-web:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
# WZP WASM module (needed by audio variants loaded from /audio/js/)
|
||||||
|
handle /audio-wasm/* {
|
||||||
|
uri strip_prefix /audio-wasm
|
||||||
|
reverse_proxy wzp-web:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy warzone-server:7700
|
||||||
|
}
|
||||||
42
warzone/deploy/docker/Caddyfile.test
Normal file
42
warzone/deploy/docker/Caddyfile.test
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
email admin@manko.yoga
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wildcard cert for all variant subdomains
|
||||||
|
*.voip.manko.yoga {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {$CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Route each subdomain to wzp-web with the right variant
|
||||||
|
@v1 host v1.voip.manko.yoga
|
||||||
|
@v2 host v2.voip.manko.yoga
|
||||||
|
@v3 host v3.voip.manko.yoga
|
||||||
|
@v4 host v4.voip.manko.yoga
|
||||||
|
@v5 host v5.voip.manko.yoga
|
||||||
|
@v6 host v6.voip.manko.yoga
|
||||||
|
|
||||||
|
# Rewrite root path to include variant param
|
||||||
|
rewrite @v1 / /?variant=pure
|
||||||
|
rewrite @v2 / /?variant=hybrid
|
||||||
|
rewrite @v3 / /?variant=full
|
||||||
|
rewrite @v4 / /?variant=ws
|
||||||
|
rewrite @v5 / /?variant=ws-fec
|
||||||
|
rewrite @v6 / /?variant=ws-full
|
||||||
|
|
||||||
|
# All subdomains proxy to wzp-web
|
||||||
|
reverse_proxy wzp-web:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main domain — featherChat server
|
||||||
|
voip.manko.yoga {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {$CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /audio/* {
|
||||||
|
reverse_proxy wzp-web:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy warzone-server:7700
|
||||||
|
}
|
||||||
12
warzone/deploy/docker/Dockerfile.caddy
Normal file
12
warzone/deploy/docker/Dockerfile.caddy
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Caddy with Cloudflare DNS plugin — builds for any arch
|
||||||
|
FROM caddy:2-builder AS builder
|
||||||
|
|
||||||
|
# Force IPv4-only for Go module downloads (Docker build may lack IPv6)
|
||||||
|
ENV GOFLAGS="-mod=mod"
|
||||||
|
RUN echo 'precedence ::ffff:0:0/96 100' > /etc/gai.conf && \
|
||||||
|
xcaddy build \
|
||||||
|
--with github.com/caddy-dns/cloudflare
|
||||||
|
|
||||||
|
FROM caddy:2
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
30
warzone/deploy/docker/Dockerfile.server
Normal file
30
warzone/deploy/docker/Dockerfile.server
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# featherChat server — multi-stage build
|
||||||
|
# Build context: featherChat repo root (../../..)
|
||||||
|
FROM rust:latest AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy warzone workspace
|
||||||
|
COPY warzone/Cargo.toml warzone/Cargo.lock ./warzone/
|
||||||
|
COPY warzone/crates ./warzone/crates
|
||||||
|
|
||||||
|
WORKDIR /build/warzone
|
||||||
|
|
||||||
|
# Build WASM first (server embeds it via include_str!/include_bytes!)
|
||||||
|
RUN cargo install wasm-pack && \
|
||||||
|
wasm-pack build crates/warzone-wasm --target web --out-dir /build/warzone/wasm-pkg 2>&1 || true
|
||||||
|
|
||||||
|
# Build server (now wasm-pkg exists at the expected relative path)
|
||||||
|
RUN cargo build --release --bin warzone-server
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /build/warzone/target/release/warzone-server /usr/local/bin/
|
||||||
|
|
||||||
|
WORKDIR /data
|
||||||
|
EXPOSE 7700
|
||||||
|
|
||||||
|
ENTRYPOINT ["warzone-server"]
|
||||||
|
CMD ["--bind", "0.0.0.0:7700"]
|
||||||
30
warzone/deploy/docker/Dockerfile.wzp
Normal file
30
warzone/deploy/docker/Dockerfile.wzp
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# WZP relay + web bridge — multi-stage build
|
||||||
|
# Build context: featherChat repo root (../../..)
|
||||||
|
FROM rust:latest AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y cmake pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy warzone-phone workspace (feature/wzp-web-variants branch)
|
||||||
|
COPY warzone-phone/Cargo.toml warzone-phone/Cargo.lock ./warzone-phone/
|
||||||
|
COPY warzone-phone/crates ./warzone-phone/crates
|
||||||
|
|
||||||
|
# wzp-crypto depends on warzone-protocol via deps/featherchat/warzone/...
|
||||||
|
COPY warzone/crates/warzone-protocol ./warzone-phone/deps/featherchat/warzone/crates/warzone-protocol
|
||||||
|
|
||||||
|
# Build both binaries
|
||||||
|
WORKDIR /build/warzone-phone
|
||||||
|
RUN cargo build --release --bin wzp-relay --bin wzp-web
|
||||||
|
|
||||||
|
# Runtime — use same distro as builder to match glibc
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /build/warzone-phone/target/release/wzp-relay /usr/local/bin/
|
||||||
|
COPY --from=builder /build/warzone-phone/target/release/wzp-web /usr/local/bin/
|
||||||
|
|
||||||
|
# Copy static files for wzp-web (HTML, JS, WASM)
|
||||||
|
COPY --from=builder /build/warzone-phone/crates/wzp-web/static /data/static
|
||||||
|
|
||||||
|
WORKDIR /data
|
||||||
24
warzone/deploy/docker/docker-compose.ipv6.yml
Normal file
24
warzone/deploy/docker/docker-compose.ipv6.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# IPv6 overlay — use with:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.ipv6.yml up -d
|
||||||
|
#
|
||||||
|
# Requires Docker daemon IPv6 support:
|
||||||
|
# /etc/docker/daemon.json: {"ipv6": true, "fixed-cidr-v6": "fd00::/80"}
|
||||||
|
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
ports:
|
||||||
|
- "[::]:80:80"
|
||||||
|
- "[::]:443:443"
|
||||||
|
- "[::]:443:443/udp"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
enable_ipv6: true
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: fd00:cafe:1::/64
|
||||||
|
backend:
|
||||||
|
enable_ipv6: true
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: fd00:cafe:2::/64
|
||||||
97
warzone/deploy/docker/docker-compose.yml
Normal file
97
warzone/deploy/docker/docker-compose.yml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# featherChat + WZP full stack
|
||||||
|
# Usage:
|
||||||
|
# echo "YOUR_CF_API_TOKEN" > cf_api_token.txt
|
||||||
|
# docker compose up -d
|
||||||
|
#
|
||||||
|
# DNS: voip.manko.yoga → your IP
|
||||||
|
# Test: https://voip.manko.yoga
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─── Caddy reverse proxy (TLS termination) ───
|
||||||
|
caddy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp" # HTTP/3 (QUIC)
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
secrets:
|
||||||
|
- cf_api_token
|
||||||
|
entrypoint: ["/bin/sh", "-c", "export CF_API_TOKEN=$(cat /run/secrets/cf_api_token) && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"]
|
||||||
|
depends_on:
|
||||||
|
- warzone-server
|
||||||
|
- wzp-web
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
|
||||||
|
# ─── featherChat server ───
|
||||||
|
warzone-server:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: warzone/deploy/docker/Dockerfile.server
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
WZP_RELAY_ADDR: "voip.manko.yoga/audio"
|
||||||
|
RUST_LOG: "info"
|
||||||
|
volumes:
|
||||||
|
- server_data:/data
|
||||||
|
command: ["--bind", "0.0.0.0:7700", "--enable-bots"]
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
# ─── WZP QUIC relay (audio SFU) ───
|
||||||
|
wzp-relay:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: warzone/deploy/docker/Dockerfile.wzp
|
||||||
|
restart: unless-stopped
|
||||||
|
entrypoint: ["wzp-relay"]
|
||||||
|
command:
|
||||||
|
- "--listen"
|
||||||
|
- "0.0.0.0:4433"
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
ipv4_address: 172.28.0.10
|
||||||
|
|
||||||
|
# ─── WZP web bridge (browser WS ↔ QUIC relay) ───
|
||||||
|
# No --tls (Caddy handles TLS), no --auth-url (Caddy terminates)
|
||||||
|
# Variants: ?variant=pure|hybrid|full
|
||||||
|
wzp-web:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: warzone/deploy/docker/Dockerfile.wzp
|
||||||
|
restart: unless-stopped
|
||||||
|
entrypoint: ["wzp-web"]
|
||||||
|
command:
|
||||||
|
- "--port"
|
||||||
|
- "8080"
|
||||||
|
- "--relay"
|
||||||
|
- "172.28.0.10:4433"
|
||||||
|
depends_on:
|
||||||
|
- wzp-relay
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
cf_api_token:
|
||||||
|
file: ./cf_api_token.txt
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
server_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
backend:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.28.0.0/24
|
||||||
58
warzone/deploy/docker/test-stack.sh
Executable file
58
warzone/deploy/docker/test-stack.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HOST="${1:-voip.manko.yoga}"
|
||||||
|
SCHEME="${2:-https}"
|
||||||
|
|
||||||
|
echo "=== featherChat Stack Test ==="
|
||||||
|
echo "Host: $HOST ($SCHEME)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Web UI
|
||||||
|
echo -n "1. Web UI (GET /)... "
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/")
|
||||||
|
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
|
||||||
|
|
||||||
|
# 2. API health
|
||||||
|
echo -n "2. API health... "
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/v1/health")
|
||||||
|
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
|
||||||
|
|
||||||
|
# 3. WASM module
|
||||||
|
echo -n "3. WASM module... "
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/wasm/warzone_wasm.js")
|
||||||
|
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
|
||||||
|
|
||||||
|
# 4. WZP relay config
|
||||||
|
echo -n "4. WZP relay config... "
|
||||||
|
RELAY=$(curl -s "$SCHEME://$HOST/v1/wzp/relay-config")
|
||||||
|
echo "$RELAY" | grep -q "relay_addr" && echo "OK ($(echo $RELAY | python3 -c 'import sys,json; print(json.load(sys.stdin).get("relay_addr","?"))' 2>/dev/null))" || echo "FAIL"
|
||||||
|
|
||||||
|
# 5. Audio bridge (wzp-web via Caddy /audio path)
|
||||||
|
echo -n "5. Audio bridge (GET /audio/)... "
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/audio/")
|
||||||
|
# wzp-web returns 200 for its landing page
|
||||||
|
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "WARN ($STATUS — wzp-web may not serve GET /)"
|
||||||
|
|
||||||
|
# 6. WebSocket upgrade test
|
||||||
|
echo -n "6. WS upgrade test... "
|
||||||
|
WS_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Upgrade: websocket" -H "Connection: Upgrade" "$SCHEME://$HOST/v1/ws/test")
|
||||||
|
echo "($WS_STATUS)"
|
||||||
|
|
||||||
|
# 7. TLS cert check
|
||||||
|
if [ "$SCHEME" = "https" ]; then
|
||||||
|
echo -n "7. TLS cert... "
|
||||||
|
ISSUER=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null)
|
||||||
|
echo "$ISSUER" | grep -q "Let's Encrypt\|Cloudflare\|R3\|E1" && echo "OK ($ISSUER)" || echo "$ISSUER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8. IPv6 test
|
||||||
|
echo -n "8. IPv6... "
|
||||||
|
if curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 3 "$SCHEME://$HOST/" 2>/dev/null; then
|
||||||
|
echo " (IPv6 reachable)"
|
||||||
|
else
|
||||||
|
echo "not available (IPv4 only)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Done ==="
|
||||||
98
warzone/deploy/docker/update-dns.sh
Executable file
98
warzone/deploy/docker/update-dns.sh
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Updates voip.manko.yoga DNS records with current public IPs.
|
||||||
|
# Usage:
|
||||||
|
# ./update-dns.sh Loop every 5 minutes
|
||||||
|
# ./update-dns.sh --once Run once and exit
|
||||||
|
#
|
||||||
|
# Reads CF_API_TOKEN env var or deploy/docker/cf_api_token.txt
|
||||||
|
|
||||||
|
DOMAIN="voip.manko.yoga"
|
||||||
|
ZONE="manko.yoga"
|
||||||
|
INTERVAL="${DNS_UPDATE_INTERVAL:-300}"
|
||||||
|
|
||||||
|
get_token() {
|
||||||
|
if [ -n "${CF_API_TOKEN:-}" ]; then
|
||||||
|
echo "$CF_API_TOKEN"
|
||||||
|
elif [ -f /run/secrets/cf_api_token ]; then
|
||||||
|
cat /run/secrets/cf_api_token | tr -d '\n'
|
||||||
|
else
|
||||||
|
echo "ERROR: no CF token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_zone_id() {
|
||||||
|
curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" \
|
||||||
|
-H "Authorization: Bearer $(get_token)" | \
|
||||||
|
python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
get_public_ipv4() {
|
||||||
|
curl -4 -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || \
|
||||||
|
curl -4 -s --connect-timeout 5 https://ifconfig.me 2>/dev/null || echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
get_public_ipv6() {
|
||||||
|
curl -6 -s --connect-timeout 5 https://api6.ipify.org 2>/dev/null || \
|
||||||
|
curl -6 -s --connect-timeout 5 https://ifconfig.co 2>/dev/null || echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert_record() {
|
||||||
|
local zone_id="$1" type="$2" content="$3" token
|
||||||
|
token=$(get_token)
|
||||||
|
|
||||||
|
local existing
|
||||||
|
existing=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
|
||||||
|
-H "Authorization: Bearer $token")
|
||||||
|
|
||||||
|
local rec_id current
|
||||||
|
rec_id=$(echo "$existing" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null)
|
||||||
|
current=$(echo "$existing" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['content'] if r else '')" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$current" = "$content" ]; then
|
||||||
|
echo " $type: $content (unchanged)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$rec_id" ]; then
|
||||||
|
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||||
|
echo " $type: $current -> $content (updated)"
|
||||||
|
else
|
||||||
|
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
|
||||||
|
echo " $type: $content (created)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
echo "[$(date -u +%H:%M:%S)] Updating DNS for $DOMAIN..."
|
||||||
|
local zone_id
|
||||||
|
zone_id=$(get_zone_id)
|
||||||
|
if [ -z "$zone_id" ]; then
|
||||||
|
echo " ERROR: cannot get zone ID"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ipv4 ipv6
|
||||||
|
ipv4=$(get_public_ipv4)
|
||||||
|
ipv6=$(get_public_ipv6)
|
||||||
|
|
||||||
|
[ -n "$ipv4" ] && upsert_record "$zone_id" "A" "$ipv4" || echo " A: no IPv4"
|
||||||
|
[ -n "$ipv6" ] && upsert_record "$zone_id" "AAAA" "$ipv6" || echo " AAAA: no IPv6"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
if [ "${1:-}" = "--once" ]; then
|
||||||
|
update
|
||||||
|
else
|
||||||
|
update
|
||||||
|
while true; do
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
update
|
||||||
|
done
|
||||||
|
fi
|
||||||
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
|
||||||
752
warzone/docs/ARCHITECTURE.md
Normal file
752
warzone/docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,752 @@
|
|||||||
|
# Warzone Messenger (featherChat) — Architecture
|
||||||
|
|
||||||
|
**Version:** 0.0.46
|
||||||
|
**Status:** Phase 1 + Phase 2 + Phase 3 + WZP Integration + Federation + Bots + Admin
|
||||||
|
|
||||||
|
**Features:** E2E encrypted messaging (Double Ratchet), group messaging (Sender Keys), voice calls (DM E2E + group transport-encrypted), ring tones (Web Audio API), browser call notifications, group calls (`/gcall`, `/gjoin`, `/gleave-call`), read receipts (sent/delivered/read indicators), markdown rendering (TUI + Web), Telegram-compatible Bot API, admin commands, federation, device management, aliases, ETH address display, file transfer, friend lists, encrypted history backup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.46)
|
||||||
|
├── 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/groups/:name/signal Group call signal broadcast
|
||||||
|
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) |
|
||||||
|
| `POST /v1/groups/:name/signal` | Broadcast call signal to group members |
|
||||||
|
| `GET /v1/presence/:fp` | Check if peer is online |
|
||||||
|
| `GET /v1/wzp/relay-config` | Get relay address + service token |
|
||||||
|
|
||||||
|
### Ring Tones
|
||||||
|
|
||||||
|
- **Incoming call:** Web Audio API oscillator playing a 440/480 Hz dual-tone pattern (classic North American ring cadence)
|
||||||
|
- **Outgoing ringback:** 2 seconds on / 4 seconds off pattern until callee answers or rejects
|
||||||
|
- **Browser notifications:** If the web client tab is in background, an incoming call triggers a system notification so the user does not miss it
|
||||||
|
|
||||||
|
### Group Calls
|
||||||
|
|
||||||
|
- `/gcall <group>` starts a group call room; `/gjoin <group>` joins an existing room; `/gleave-call` leaves
|
||||||
|
- Group call signals are broadcast via `POST /v1/groups/:name/signal` (fan-out to all online members)
|
||||||
|
- Room naming convention: DM calls use a sorted fingerprint pair as room ID; group calls use `gc-<groupname>`
|
||||||
|
- **Encryption:** Group calls are transport-encrypted only (QUIC with TLS). They are NOT end-to-end encrypted. MLS (RFC 9420) key agreement for group call media is on the roadmap.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
- **BotFather** creates bots and issues tokens; each bot gets an auto-registered alias
|
||||||
|
- Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced); non-bot users cannot register reserved aliases
|
||||||
|
- **Per-bot numeric ID mapping:** Each user is assigned a unique numeric ID per bot, preventing cross-bot user correlation (privacy)
|
||||||
|
- **Telegram-compatible endpoints:** `getUpdates` (long-poll), `sendMessage`, `editMessage`, `sendDocument`, inline keyboards
|
||||||
|
- `sendMessage` delivers plaintext (no E2E in v1 — bot messages are not encrypted)
|
||||||
|
- Messages from users arrive as encrypted blobs (base64) or plaintext bot messages
|
||||||
|
- **System bots:** Configured via `--bots-config <file>` on server startup; auto-created on first run
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
| DM calls (voice) | E2E encrypted | ChaCha20-Poly1305 over QUIC via WZP relay |
|
||||||
|
| Group calls (voice) | Transport-encrypted only | QUIC/TLS — NOT E2E (MLS on roadmap) |
|
||||||
|
|
||||||
|
### 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 | 39 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client, receipts |
|
||||||
|
| warzone-client (types) | 10 | App init, scroll, connected, timestamps, normfp |
|
||||||
|
| warzone-client (input) | 25 | Text editing, cursor movement, scroll keys, quit |
|
||||||
|
| warzone-client (draw) | 13 | Rendering, timestamps, connection dot, scroll, unread badge, markdown |
|
||||||
|
| warzone-server (integration) | 10 | Route handlers, auth middleware, group ops, call state |
|
||||||
|
| warzone-server (bin) | 10 | CLI args, startup, federation init, bot config |
|
||||||
|
| Other (e2e, misc) | 48 | Client-side E2E flows, file transfer, admin commands |
|
||||||
|
| **Total** | **155** | 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/admin-calls` | List all currently active calls on the server |
|
||||||
|
| `/admin-unalias <alias> <pw>` | Force-remove an alias (requires admin password) |
|
||||||
|
| `/admin-help` | Show available admin commands |
|
||||||
|
|
||||||
|
Admin commands are available in the TUI client and are authenticated server-side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read Receipts
|
||||||
|
|
||||||
|
- **TUI:** Tracks which messages are visible in the viewport and sends `Receipt::Read` back to the sender when a message scrolls into view
|
||||||
|
- **Web:** Sender sees delivery indicators: single check mark (sent) then double check mark (delivered) then blue double check mark (read)
|
||||||
|
- **Deduplication:** Each message is receipted only once; the client tracks which message IDs have already been acknowledged to avoid redundant receipt traffic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Markdown Rendering
|
||||||
|
|
||||||
|
- **TUI:** Custom `md_to_spans` parser converts markdown to ratatui `Span` objects supporting bold, italic, inline code, headers, blockquotes, and lists
|
||||||
|
- **Web:** `renderMd()` function in the embedded JS handles code blocks, inline code, bold, italic, headers, links, blockquotes, and ordered/unordered lists
|
||||||
|
- Both renderers are deliberately simple (no AST) to avoid pulling in heavy markdown dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues and Limitations
|
||||||
|
|
||||||
|
| Issue | Details |
|
||||||
|
|-------|---------|
|
||||||
|
| Group call signal delivery | Depends on members being online; there is no offline queue for call signals |
|
||||||
|
| TUI voice calls | Require the web client; no native audio (cpal) integration yet |
|
||||||
|
| Bot messages are plaintext | v1 limitation; bots cannot participate in E2E encryption |
|
||||||
|
| `/gmembers` ETH resolution | Async resolution may briefly show the raw fingerprint before the ETH address loads |
|
||||||
|
| Service worker cache staleness | Cache version in `web.rs` must be bumped on every change or browsers will serve stale WASM/JS content |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
638
warzone/docs/CLIENT.md
Normal file
638
warzone/docs/CLIENT.md
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
# Warzone Client -- Operation Guide
|
||||||
|
|
||||||
|
**Version:** 0.0.46
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Voice Calls
|
||||||
|
|
||||||
|
The TUI supports DM and group call commands:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/call [peer]` | Initiate a voice call with the current or specified peer |
|
||||||
|
| `/accept` | Accept an incoming call |
|
||||||
|
| `/reject` | Reject an incoming call |
|
||||||
|
| `/hangup` | End the current call |
|
||||||
|
|
||||||
|
**Call state display:** The TUI header bar shows call status with color coding:
|
||||||
|
|
||||||
|
- **Yellow "CALLING..."** — outgoing call ringing, waiting for peer to accept
|
||||||
|
- **Green "IN CALL" + timer** — active call with elapsed duration (MM:SS)
|
||||||
|
- No indicator when idle
|
||||||
|
|
||||||
|
**Note:** TUI audio requires the web client. When a call is active in the TUI, a hint is displayed directing the user to open the web client for actual audio. The TUI handles signaling (offer/answer/ICE) but does not capture or play audio.
|
||||||
|
|
||||||
|
### Read Receipts
|
||||||
|
|
||||||
|
Read receipts track message delivery through three states: sent, delivered, and read.
|
||||||
|
|
||||||
|
- **Sender fingerprint tracking:** Each outgoing message records the sender's fingerprint so the system can match incoming receipts to the correct message.
|
||||||
|
- **Dedup set:** A per-conversation set prevents sending duplicate read receipts for the same message. Once a read receipt is sent for a message ID, it is not sent again.
|
||||||
|
- **Viewport-based:** Read receipts are triggered when a message scrolls into the visible area of the chat. Messages that are never scrolled into view do not generate read receipts.
|
||||||
|
|
||||||
|
### Markdown Rendering
|
||||||
|
|
||||||
|
Messages support inline markdown formatting via the `md_to_spans` function, which converts markdown syntax into ratatui `Span` elements with appropriate styling:
|
||||||
|
|
||||||
|
| Syntax | TUI Rendering |
|
||||||
|
|--------|---------------|
|
||||||
|
| `**bold**` | Bold attribute |
|
||||||
|
| `*italic*` | Italic attribute |
|
||||||
|
| `` `code` `` | Dark gray background, monospace feel |
|
||||||
|
| `# Header` | Bold + uppercase (line start only) |
|
||||||
|
| `> quote` | Italic + gray foreground (line start only) |
|
||||||
|
| `- list item` | Bullet prefix (line start only) |
|
||||||
|
|
||||||
|
Markdown is parsed per-message at render time. The web client renders the same syntax as HTML elements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
289
warzone/docs/LLM_BOT_DEV.md
Normal file
289
warzone/docs/LLM_BOT_DEV.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# 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 and Group Calls
|
||||||
|
|
||||||
|
Bots cannot initiate or participate in voice calls or group 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. Group call signals (`/gcall`, `/gjoin`, etc.) are similarly not actionable by bots.
|
||||||
|
|
||||||
|
## Markdown Rendering
|
||||||
|
|
||||||
|
Bot replies support inline markdown formatting in both the web and TUI clients:
|
||||||
|
- `**bold**` or `<b>bold</b>` (with `parse_mode: "HTML"`)
|
||||||
|
- `*italic*` or `<i>italic</i>`
|
||||||
|
- `` `inline code` `` or `<code>code</code>`
|
||||||
|
- `[link text](url)` or `<a href="url">text</a>`
|
||||||
|
- ` ```block``` ` for code blocks
|
||||||
|
|
||||||
|
When using `parse_mode: "HTML"`, the HTML tags are rendered. Without `parse_mode`, the web client renders markdown syntax natively. Both paths produce styled output.
|
||||||
|
|
||||||
|
## Per-Bot Numeric IDs
|
||||||
|
|
||||||
|
Each bot sees a unique numeric ID for each user (`from.id` in updates). These IDs are:
|
||||||
|
- Deterministic: the same user always maps to the same numeric ID for a given bot
|
||||||
|
- Per-bot unique: different bots see different numeric IDs for the same user
|
||||||
|
- Privacy-preserving: bots cannot correlate users across bots or recover raw fingerprints from the numeric ID
|
||||||
|
- Derived via HMAC of the user's fingerprint keyed with the bot's token prefix
|
||||||
|
|
||||||
|
Use `from.id` (or `chat.id`) as-is for replies. Do not attempt to reverse it to a fingerprint.
|
||||||
|
|
||||||
|
## 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)
|
||||||
295
warzone/docs/LLM_HELP.md
Normal file
295
warzone/docs/LLM_HELP.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# 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
|
||||||
|
/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
|
||||||
|
/gcall | start group voice call in current group | /gcall
|
||||||
|
/gjoin | join active group call | /gjoin
|
||||||
|
/gleave-call | leave group call (stay in group) | /gleave-call
|
||||||
|
/gmute | toggle mute in group call | /gmute
|
||||||
|
/admin-calls | list active calls on server (admin) | /admin-calls
|
||||||
|
/admin-help | show admin commands (admin) | /admin-help
|
||||||
|
/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
|
||||||
|
|
||||||
|
### Relay Config Flow
|
||||||
|
|
||||||
|
1. Client calls `GET /v1/wzp/relay-config` with bearer token
|
||||||
|
2. Server validates auth, issues a short-lived WZP token
|
||||||
|
3. Response: `{"relay_addr":"host:port","token":"..."}`
|
||||||
|
4. Client opens WebSocket to `ws://relay_addr` with the WZP token
|
||||||
|
5. Audio frames flow over the WebSocket via the wzp-web bridge
|
||||||
|
|
||||||
|
### Ring Tones
|
||||||
|
|
||||||
|
Ring tones play automatically using the Web Audio API (oscillator-based, no audio files):
|
||||||
|
- **Outgoing call**: caller hears a ringback tone (repeating double beep) while waiting for answer
|
||||||
|
- **Incoming call**: callee hears a ringing tone (classic ring pattern) until they accept/reject
|
||||||
|
- Both tones stop immediately on answer, reject, or hangup
|
||||||
|
- TUI clients receive a terminal bell on incoming call (no audio playback)
|
||||||
|
|
||||||
|
### Group Calls
|
||||||
|
|
||||||
|
Group voice calls use the same WZP relay infrastructure but with room-based routing:
|
||||||
|
|
||||||
|
```
|
||||||
|
Members A,B,C <--WS--> wzp-web <--QUIC--> wzp-relay (room: group:<group_name>)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/gcall` signals all group members via the group signal endpoint (`POST /v1/groups/:name/signal`)
|
||||||
|
- Room name format: `group:<group_name>` (e.g., `group:ops`)
|
||||||
|
- Any member can `/gjoin` an active group call
|
||||||
|
- `/gleave-call` leaves the audio room but stays in the text group
|
||||||
|
- `/gmute` toggles local mic mute (no server-side mixing)
|
||||||
|
- Group calls are transport-encrypted only; MLS (RFC 9420) E2E planned
|
||||||
|
|
||||||
|
### Admin Commands
|
||||||
|
|
||||||
|
cmd | action | example
|
||||||
|
--- | --- | ---
|
||||||
|
/admin-calls | show all active calls on the server | /admin-calls
|
||||||
|
/admin-help | list available admin commands | /admin-help
|
||||||
|
|
||||||
|
Admin commands require server-side admin privilege (configured per-fingerprint).
|
||||||
|
|
||||||
|
## 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/*
|
||||||
281
warzone/docs/PROGRESS.md
Normal file
281
warzone/docs/PROGRESS.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Warzone Messenger (featherChat) — Progress Report
|
||||||
|
|
||||||
|
**Current Version:** 0.0.46
|
||||||
|
**Last Updated:** 2026-03-30
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
| Version | Date | Highlights |
|
||||||
|
|---------|------|------------|
|
||||||
|
| 0.0.22 | 2026-03-28 | ETH identity in web client |
|
||||||
|
| 0.0.23-24 | 2026-03-28 | ETH display everywhere (TUI + Web) |
|
||||||
|
| 0.0.25-26 | 2026-03-28 | Federation persistent WS, text selection |
|
||||||
|
| 0.0.27-29 | 2026-03-29 | Bot API: BotFather, getUpdates, sendMessage |
|
||||||
|
| 0.0.30-31 | 2026-03-29 | Bot numeric IDs, inline keyboards |
|
||||||
|
| 0.0.32-33 | 2026-03-29 | System bots config, version bump |
|
||||||
|
| 0.0.34 | 2026-03-29 | Bot sendMessage fix, per-bot ID mapping |
|
||||||
|
| 0.0.35 | 2026-03-29 | WASM create_call_signal, selectable identity |
|
||||||
|
| 0.0.36 | 2026-03-29 | Web call UI (call/accept/reject/hangup) |
|
||||||
|
| 0.0.37 | 2026-03-29 | TUI call state UI, missed calls, inline keyboards |
|
||||||
|
| 0.0.38 | 2026-03-29 | Session versioning, wire envelope, auto-backup |
|
||||||
|
| 0.0.39 | 2026-03-30 | Contacts online, message wrap, tab complete, OTPK |
|
||||||
|
| 0.0.40 | 2026-03-30 | Call reload, ETH cache prefill, 10 server tests |
|
||||||
|
| 0.0.41 | 2026-03-30 | Read receipts (viewport tracking) |
|
||||||
|
| 0.0.42 | 2026-03-30 | Markdown rendering in TUI messages |
|
||||||
|
| 0.0.43 | 2026-03-30 | Voice calls via WZP audio bridge |
|
||||||
|
| 0.0.44 | 2026-03-30 | Web UI polish, ETH display, call routing fixes |
|
||||||
|
| 0.0.45 | 2026-03-30 | Call ring tones + group calls |
|
||||||
|
| 0.0.46 | 2026-03-30 | Group call fixes, admin commands, ETH in members |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Version: v0.0.46
|
||||||
|
|
||||||
|
### Codebase Statistics
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|-------------------|--------------------------------|
|
||||||
|
| Crates | 5 (protocol, server, client, wasm, mule) |
|
||||||
|
| Total tests | ~155 (protocol + client + server) |
|
||||||
|
| Server routes | 12 files, 15+ 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 (viewport tracking)
|
||||||
|
- 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 (displayed in TUI + Web)
|
||||||
|
- Encrypted backup and restore (with auto-backup)
|
||||||
|
- Contact list and message history
|
||||||
|
- Multi-device support (basic)
|
||||||
|
- Bot API with BotFather (Telegram-compatible)
|
||||||
|
- Voice calls (1:1 via WZP, Web audio bridge)
|
||||||
|
- Group calls (transport-encrypted, fan-out signaling)
|
||||||
|
- Call ring tones (Web Audio API oscillators)
|
||||||
|
- Markdown rendering in TUI + Web messages
|
||||||
|
- Federation with persistent WebSocket
|
||||||
|
- Admin commands
|
||||||
|
- Session state versioning + wire envelope format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Suite
|
||||||
|
|
||||||
|
~155 tests across protocol + client + server 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 |
|
||||||
|
|
||||||
|
### Server Tests (10+)
|
||||||
|
|
||||||
|
| Area | Tests | Coverage |
|
||||||
|
|---------------|-------|---------------------------------------------|
|
||||||
|
| integration | 10+ | Call reload, ETH cache, presence, routing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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. |
|
||||||
|
| Lookbehind regex | 0.0.42 | JS lookbehind regex broke Safari markdown rendering. Replaced with forward-compatible pattern. |
|
||||||
|
| Resolve parens warning | 0.0.43 | Unnecessary parentheses in resolve.rs caused compiler warning. Removed. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues and Limitations
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
1. **Group call signals only reach online members:** Offline members do not receive group call join signals. They must be online when the call starts.
|
||||||
|
|
||||||
|
2. **TUI voice needs web client:** The TUI cannot capture/play audio natively; voice calls require the web client with WZP audio bridge. TUI voice via cpal is planned (FC-P7-T1).
|
||||||
|
|
||||||
|
3. **Bot messages are plaintext:** Bot API messages are not E2E encrypted (v1 design decision). Bots see and send cleartext.
|
||||||
|
|
||||||
|
4. **Group calls are transport-encrypted only:** Group call audio is encrypted by QUIC on the wire but the WZP relay can see plaintext audio. MLS E2E encryption is planned (FC-P5-T5).
|
||||||
|
|
||||||
|
5. **Service worker cache must be bumped:** After WASM changes, the `wz-vN` cache version in web.rs must be incremented or browsers serve stale code.
|
||||||
|
|
||||||
|
### Existing Limitations
|
||||||
|
|
||||||
|
6. **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.
|
||||||
|
|
||||||
|
7. **No sealed sender:** The server sees sender and recipient fingerprints in message routing metadata.
|
||||||
|
|
||||||
|
8. **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.
|
||||||
|
|
||||||
|
9. **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.
|
||||||
|
|
||||||
|
10. **Single server only:** No full federation between servers yet. Persistent WS relay exists but full DNS discovery is planned.
|
||||||
|
|
||||||
|
11. **No push notifications:** Users must keep a WebSocket connection open or poll.
|
||||||
|
|
||||||
|
12. **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.
|
||||||
|
|
||||||
|
13. **Web client: localStorage only:** Seed and session data stored in browser localStorage. Clearing browser data = lost identity.
|
||||||
|
|
||||||
|
14. **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
|
||||||
|
|
||||||
|
### Priority Order (Updated v0.0.46)
|
||||||
|
|
||||||
|
1. **TUI voice via cpal (FC-P7-T1)** — native audio capture/playback
|
||||||
|
2. **Web extract (FC-P3-T5)** — extract web.rs monolith into separate files
|
||||||
|
3. **MLS group E2E (FC-P5-T5)** — RFC 9420 for group call encryption
|
||||||
|
4. **Sender Keys for DM call E2E (FC-P7-T2)** — encrypted call signaling
|
||||||
|
5. **WebTransport (FC-P7-T3)** — replace wzp-web bridge
|
||||||
|
6. Federation (Phase 3) — DNS discovery + multi-server
|
||||||
|
7. Mule protocol (Phase 4) — physical delivery
|
||||||
|
8. Polish (FC-P6) — search, reactions, typing indicators, virtual scroll
|
||||||
|
|
||||||
|
See `TASK_PLAN.md` for the detailed task breakdown with IDs and dependencies.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user