Compare commits
165 Commits
fe6ea164bf
...
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 | ||
|
|
e364f437a2 | ||
|
|
7451ad69bc | ||
|
|
651396fa13 | ||
|
|
1e2a83402d | ||
|
|
fa20607e35 | ||
|
|
b7aa1a10e8 | ||
|
|
93c8c84de1 | ||
|
|
811dd2c008 | ||
|
|
93be964d52 | ||
|
|
04482faa6a | ||
|
|
03d91cb844 | ||
|
|
c97a3834d1 | ||
|
|
087334ffe9 |
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
|
||||
602
DESIGN.md
Normal file
602
DESIGN.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# Warzone Messenger — Design Document
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Current chat.py has fundamental issues:
|
||||
- **Identity is username-based** — users pick names, no cryptographic identity. Device change = lost keys = broken encryption.
|
||||
- **No forward secrecy** — same ECDH key pair forever. Compromise one key, read all past messages.
|
||||
- **No offline delivery** — if you're not connected, messages are lost.
|
||||
- **Single server** — no federation, no redundancy. Server goes down = no chat.
|
||||
- **Python** — too slow for real deployment, hard to distribute as a single binary.
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. **Identity = key pair** — your identity IS your private key seed. No usernames, no accounts.
|
||||
2. **Signal-grade encryption** — Double Ratchet for 1:1, Sender Keys for groups.
|
||||
3. **Federation via DNS** — servers discover each other using TXT records, like Matrix but simpler.
|
||||
4. **Warzone-grade delivery** — assumes intermittent connectivity, supports mule-based physical delivery.
|
||||
5. **Single binary** — Rust, compiles to one static binary per platform.
|
||||
6. **ntfy for push** — leverage existing notification infrastructure, no custom push servers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Identity Model
|
||||
|
||||
### Seed-Based Identity
|
||||
|
||||
```
|
||||
seed (32 bytes) → Ed25519 signing keypair + X25519 encryption keypair
|
||||
```
|
||||
|
||||
- **Seed**: 32 random bytes, displayed as BIP39 mnemonic (24 words) for human backup
|
||||
- **Signing key (Ed25519)**: signs messages, proves identity
|
||||
- **Encryption key (X25519)**: used in key exchange for E2E encryption
|
||||
- **Fingerprint**: `SHA-256(public_signing_key)[:16]` displayed as hex, e.g. `a3f8:c912:44be:7d01`
|
||||
- **Display name**: optional, self-assigned, NOT part of identity. Can change anytime.
|
||||
- **Address**: `fingerprint@server.example.com` — the full address includes the home server
|
||||
|
||||
### Key Storage
|
||||
|
||||
| Platform | Storage |
|
||||
|----------|---------|
|
||||
| CLI | `~/.warzone/identity.seed` (encrypted with passphrase via Argon2 + ChaCha20) |
|
||||
| Browser | IndexedDB (non-extractable CryptoKey) + seed backup prompt on first run |
|
||||
| Mobile (PWA) | Same as browser, seed shown as QR code for device transfer |
|
||||
| Hardware wallet | Seed never leaves device. Ledger/Trezor sign via USB/BT HID. (Phase 2) |
|
||||
|
||||
### Hardware Wallet Support (Phase 2)
|
||||
|
||||
Ledger and Trezor can act as the key storage backend:
|
||||
- Seed lives on the hardware wallet, never exported
|
||||
- Ed25519 signing delegated to device (BIP44 path `m/44'/1234'/0'`)
|
||||
- X25519 encryption key derived from Ed25519 via birkhoff conversion, or separate derivation path
|
||||
- Client sends challenge → wallet displays → user confirms on device → signed response
|
||||
- No passphrase needed (device handles authentication)
|
||||
- Crates: `ledger-transport` (Ledger), `trezor-client` (Trezor)
|
||||
- Protocol is unchanged — only the `KeyStore` backend differs
|
||||
|
||||
### Device Transfer
|
||||
|
||||
User scans QR code containing the seed (or types 24 words). New device derives the same keypair. Identity is portable — not tied to any server or device.
|
||||
|
||||
### Trust Model
|
||||
|
||||
First-use trust (TOFU) by default. Users can verify fingerprints out-of-band (QR code scan, read aloud). Verified contacts are pinned — if their key changes, you get a hard warning (not just a dismissible notice).
|
||||
|
||||
### Challenge: Username Squatting
|
||||
|
||||
Since identity is a fingerprint, not a name, there's no squatting. Display names are untrusted labels. The UI should prominently show fingerprints for new contacts and warn on display name collisions.
|
||||
|
||||
### Challenge: Key Loss
|
||||
|
||||
Seed IS identity. Lose seed = lose identity forever. Mitigations:
|
||||
- BIP39 mnemonic backup (write on paper)
|
||||
- Optional seed escrow to a trusted contact (Shamir's Secret Sharing, 2-of-3)
|
||||
- Server never has seed — cannot help recover
|
||||
|
||||
---
|
||||
|
||||
## 2. Encryption Protocol
|
||||
|
||||
### 1:1 Messages — Signal Double Ratchet
|
||||
|
||||
```
|
||||
Initial key exchange:
|
||||
Alice's X25519 identity key + ephemeral key
|
||||
Bob's X25519 identity key + signed pre-key + one-time pre-key
|
||||
→ X3DH → shared secret
|
||||
→ initialize Double Ratchet
|
||||
|
||||
Every message:
|
||||
Symmetric ratchet step → new message key
|
||||
Every N messages or on reply: DH ratchet step → new chain key
|
||||
→ forward secrecy: compromise current key ≠ past messages readable
|
||||
→ future secrecy: compromise current key, recovery after next DH ratchet
|
||||
```
|
||||
|
||||
**Pre-key bundles**: each user uploads signed pre-keys to their home server. Other users fetch these to initiate sessions even when the recipient is offline.
|
||||
|
||||
### Group Messages — Sender Keys (Signal protocol for groups)
|
||||
|
||||
```
|
||||
Each member generates a Sender Key (random symmetric key + chain)
|
||||
Sender Key distributed to all group members via 1:1 encrypted channels
|
||||
Messages encrypted with sender's Sender Key
|
||||
Sender Key ratchets forward on each message
|
||||
|
||||
Member join: new Sender Keys distributed to everyone
|
||||
Member leave: all members rotate their Sender Keys
|
||||
```
|
||||
|
||||
### Challenge: Group Forward Secrecy
|
||||
|
||||
Sender Keys don't provide per-message forward secrecy like Double Ratchet. Trade-off: performance (one encrypt per message vs one per member). Acceptable for groups < 100. For larger groups, consider MLS (Message Layer Security, RFC 9420).
|
||||
|
||||
### Challenge: Multi-Device
|
||||
|
||||
Each device has its own X25519 keypair derived from the seed. Sender encrypts to all of recipient's known devices. Device list is signed by the identity key and published to the home server.
|
||||
|
||||
### Message Format
|
||||
|
||||
```
|
||||
{
|
||||
"v": 1,
|
||||
"from": "a3f8c912...", // sender fingerprint
|
||||
"to": "b7d1e845...", // recipient fingerprint (or group ID)
|
||||
"ts": 1711443600,
|
||||
"type": "msg", // msg | file | key_exchange | receipt
|
||||
"session": "...", // Double Ratchet session ID
|
||||
"ratchet": { "dh": "...", "n": 42, "pn": 41 },
|
||||
"ciphertext": "base64...", // encrypted payload
|
||||
"sig": "base64..." // Ed25519 signature over everything above
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Federation via DNS
|
||||
|
||||
### Server Discovery
|
||||
|
||||
Each server operates under a domain. Federation is discovered via DNS TXT records:
|
||||
|
||||
```
|
||||
_warzone._tcp.example.com TXT "v=wz1; endpoint=https://wz.example.com; pubkey=base64..."
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `v=wz1` — protocol version
|
||||
- `endpoint` — HTTPS URL of the server API
|
||||
- `pubkey` — server's Ed25519 public key (for server-to-server auth)
|
||||
|
||||
### Server-to-Server Protocol
|
||||
|
||||
```
|
||||
Server A wants to deliver message to user@example.com:
|
||||
1. DNS lookup: _warzone._tcp.example.com → TXT record → endpoint URL
|
||||
2. TLS connection to endpoint
|
||||
3. Mutual authentication: both servers verify each other's pubkey
|
||||
4. Deliver encrypted message blob (server cannot read it)
|
||||
5. Recipient's server queues it for delivery
|
||||
```
|
||||
|
||||
### Home Server Responsibilities
|
||||
|
||||
- Store and forward messages for its users
|
||||
- Host pre-key bundles for key exchange
|
||||
- Serve user's device list and public keys
|
||||
- Relay messages to federated servers
|
||||
- Queue messages for offline users
|
||||
|
||||
### Challenge: DNS Availability in Warzone
|
||||
|
||||
DNS may be unreliable or censored. Mitigations:
|
||||
- **Hard-coded peer list**: users can manually add server endpoints
|
||||
- **DNS-over-HTTPS (DoH)**: bypass local DNS censorship
|
||||
- **mDNS/local discovery**: for LAN-only operation when internet is down
|
||||
- **Gossip protocol**: servers share their known peer list with each other
|
||||
|
||||
### Challenge: Server Impersonation
|
||||
|
||||
Server pubkey in DNS TXT record prevents impersonation. But DNS itself could be hijacked. Mitigations:
|
||||
- DNSSEC validation
|
||||
- TOFU for server keys (pin on first contact)
|
||||
- Certificate transparency-style log for server key changes
|
||||
|
||||
### Key Transparency via DNS
|
||||
|
||||
Use DNS as a decentralized public key registry — prevents the server from performing MITM attacks on key exchange.
|
||||
|
||||
Each user publishes their public key as a DNS TXT record, signed by their own identity key:
|
||||
|
||||
```
|
||||
_wz._id.<hashed-fingerprint>.example.com TXT "v=wz1; fp=a3f8c912...; pubkey=base64...; sig=base64..."
|
||||
```
|
||||
|
||||
- `fp` — full fingerprint
|
||||
- `pubkey` — user's Ed25519 public identity key
|
||||
- `sig` — self-signature over (fp + pubkey), proving the DNS record was authored by the key holder
|
||||
|
||||
**Verification flow:**
|
||||
```
|
||||
Bob wants Alice's key:
|
||||
1. Ask server → server says Alice's key is X
|
||||
2. DNS lookup → _wz._id.<hash(alice-fp)>.example.com → key is X, self-signed
|
||||
3. Match? → trusted
|
||||
4. Mismatch? → HARD WARNING: server may be performing MITM
|
||||
5. No DNS record? → fall back to TOFU (trust on first use)
|
||||
```
|
||||
|
||||
**Why DNS works here:**
|
||||
- Decentralized: no single party controls all DNS (especially across domains)
|
||||
- The self-signature in the TXT record means even the DNS admin can't forge it without Alice's private key
|
||||
- DNSSEC adds transport integrity (record wasn't tampered in transit)
|
||||
- Records are globally cached and replicated — hard to silently change
|
||||
|
||||
**Privacy concern:** public DNS means anyone can enumerate users by scanning TXT records. Mitigation: subdomain is `SHA-256(fingerprint)[:16]` — you must already know the fingerprint to look up the record. This makes enumeration impractical.
|
||||
|
||||
**Scalability:** one TXT record per user. Fine for thousands of users per domain. Large orgs can shard across subdomains.
|
||||
|
||||
**When users don't control DNS:** in an org deployment, the admin controls the DNS zone. The admin could collude with the server to MITM. But the self-signature still protects — the admin would need the user's private key to forge a valid record. The only attack is *deleting* the record (forcing TOFU fallback), not *replacing* it.
|
||||
|
||||
**Integration with federation:** the same DNS zone handles both server discovery (`_warzone._tcp`) and user key transparency (`_wz._id`). One DNS zone, two purposes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Warzone Delivery — Mule Protocol
|
||||
|
||||
### Problem
|
||||
|
||||
In conflict zones, internet connectivity is intermittent, unreliable, or surveilled. Servers may be offline for hours or days. Traditional store-and-forward fails when both servers are rarely online simultaneously.
|
||||
|
||||
### Mule Role
|
||||
|
||||
A **mule** is a device (phone, laptop, USB drive) that physically carries messages between disconnected networks.
|
||||
|
||||
```
|
||||
Network A (offline) Mule Network B (online)
|
||||
| | |
|
||||
|<-- connect to A -------| |
|
||||
|-- queued messages ---->| |
|
||||
|<-- delivery receipts --| |
|
||||
| | |
|
||||
| |--- travel physically ---->|
|
||||
| | |
|
||||
| |-- connect to B ---------->|
|
||||
| |-- deliver messages ------>|
|
||||
| |<-- queued for A ---------|
|
||||
| |<-- receipts for A --------|
|
||||
| | |
|
||||
| |<-- travel back -----------|
|
||||
| | |
|
||||
|<-- connect to A -------| |
|
||||
|<-- deliver from B -----| |
|
||||
|-- receipts for B ----->| |
|
||||
```
|
||||
|
||||
### Mule Protocol
|
||||
|
||||
1. **Authentication**: mule presents its identity (keypair). Server checks if mule is authorized (allowlist or signed authorization token from an admin).
|
||||
|
||||
2. **Pickup**: mule sends `PICKUP` request. Server gives all queued outbound messages (encrypted blobs — mule cannot read them). Server marks messages as "in transit by mule X".
|
||||
|
||||
3. **Delivery**: mule connects to destination server, sends `DELIVER` with the blobs. Destination server validates signatures and queues for recipients.
|
||||
|
||||
4. **Receipts**: destination server gives mule delivery receipts (signed). Mule carries these back.
|
||||
|
||||
5. **Receipt enforcement**: on next pickup, mule MUST present receipts for previous delivery. If no receipts → server refuses new pickup (prevents mule from dropping messages silently). Exception: mule can present a signed "delivery failed" report explaining why.
|
||||
|
||||
6. **Deduplication**: messages have unique IDs. Servers deduplicate on receive. Multiple mules can carry the same messages — first delivery wins, duplicates are silently dropped.
|
||||
|
||||
### Queue Management
|
||||
|
||||
```
|
||||
Message states on origin server:
|
||||
QUEUED → waiting for delivery (direct or mule)
|
||||
IN_TRANSIT → picked up by mule X at time T
|
||||
DELIVERED → receipt received
|
||||
EXPIRED → TTL exceeded, dropped
|
||||
|
||||
TTL: configurable per-message (default 7 days)
|
||||
Retry: if IN_TRANSIT for > 24h with no receipt, re-queue
|
||||
```
|
||||
|
||||
### Challenge: Mule Compromise
|
||||
|
||||
Mule has encrypted blobs. Even if captured:
|
||||
- Messages are E2E encrypted — mule sees only ciphertext
|
||||
- Metadata (sender/recipient fingerprints) is visible to mule. Mitigation: wrap in an outer encryption layer to the destination server's public key, so mule only sees "blob for server X"
|
||||
- Mule authorization can be revoked by server admin
|
||||
|
||||
### Challenge: Message Ordering
|
||||
|
||||
Mule delivery is inherently out-of-order. Messages carry sequence numbers per conversation. Clients reorder on display. Ratchet protocol handles out-of-order decryption natively (message keys are cached for skipped messages).
|
||||
|
||||
### Challenge: Mule Bandwidth
|
||||
|
||||
Mule may carry gigabytes of messages on a USB drive, or megabytes on a phone over Bluetooth. Protocol must support:
|
||||
- Priority levels (urgent messages first)
|
||||
- Compression (zstd on the blob bundle)
|
||||
- Partial sync (resume interrupted transfer)
|
||||
- Size limits per mule (server respects mule's capacity declaration)
|
||||
|
||||
---
|
||||
|
||||
## 5. Notification via ntfy
|
||||
|
||||
### Why ntfy
|
||||
|
||||
- Self-hostable, simple HTTP API
|
||||
- Works on Android (no Google Play Services needed), iOS, desktop
|
||||
- Supports E2E encryption (ntfy's own, separate from ours)
|
||||
- Can be deployed alongside our server
|
||||
|
||||
### Integration
|
||||
|
||||
```
|
||||
User registers ntfy topic: fingerprint-derived, e.g. wz_a3f8c912
|
||||
Server pushes notification on new message:
|
||||
POST https://ntfy.example.com/wz_a3f8c912
|
||||
Body: "New message from <display_name>"
|
||||
(NO message content — that's E2E encrypted)
|
||||
```
|
||||
|
||||
User subscribes to their topic in ntfy app. Gets push notification, opens warzone client to read the actual message.
|
||||
|
||||
### Challenge: ntfy Metadata
|
||||
|
||||
ntfy server sees that a notification was sent to a topic (i.e., someone messaged this user). Mitigation: self-host ntfy on the same server. Or accept the metadata leak as a trade-off for push notification functionality.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rust Rewrite
|
||||
|
||||
### Why Rust
|
||||
|
||||
- Single static binary (no runtime dependencies)
|
||||
- Memory safety without GC
|
||||
- Excellent async I/O (tokio)
|
||||
- Cross-compile to Linux ARM (warzone routers, phones), Windows, macOS
|
||||
- WebAssembly target for browser client
|
||||
|
||||
### Crate Selection
|
||||
|
||||
| Function | Crate |
|
||||
|----------|-------|
|
||||
| Async runtime | `tokio` |
|
||||
| HTTP server | `axum` |
|
||||
| Crypto | `ring` or `libsignal-protocol` |
|
||||
| Signal protocol | `libsignal-protocol-rust` (Signal's official Rust impl) |
|
||||
| Ed25519 | `ed25519-dalek` |
|
||||
| X25519 | `x25519-dalek` |
|
||||
| Argon2 | `argon2` |
|
||||
| DNS | `trust-dns-resolver` |
|
||||
| TLS | `rustls` |
|
||||
| Database | `sled` (embedded) or `sqlite` via `rusqlite` |
|
||||
| Serialization | `serde` + `bincode` (wire) + `serde_json` (API) |
|
||||
| BIP39 | `bip39` |
|
||||
| Compression | `zstd` |
|
||||
| CLI | `clap` |
|
||||
| TUI | `ratatui` |
|
||||
|
||||
### Binary Targets
|
||||
|
||||
```
|
||||
warzone-server # server binary
|
||||
warzone # CLI client + TUI
|
||||
warzone-mule # mule binary (subset of client)
|
||||
warzone.wasm # browser client (via wasm-pack)
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Server │
|
||||
├──────────┬──────────┬───────────────────┤
|
||||
│ HTTP API │ WS relay │ Federation (S2S) │
|
||||
├──────────┴──────────┴───────────────────┤
|
||||
│ Message Router │
|
||||
├──────────┬──────────┬───────────────────┤
|
||||
│ Queue DB │ Key Store│ User Registry │
|
||||
│ (sled) │ (sled) │ (sled) │
|
||||
└──────────┴──────────┴───────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Client │
|
||||
├──────────┬──────────┬───────────────────┤
|
||||
│ TUI │ Web(WASM)│ CLI │
|
||||
├──────────┴──────────┴───────────────────┤
|
||||
│ Protocol Layer │
|
||||
├──────────┬──────────┬───────────────────┤
|
||||
│ Signal │ Identity │ Storage │
|
||||
│ Protocol │ Manager │ (sled/IndexedDB) │
|
||||
└──────────┴──────────┴───────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Roadmap
|
||||
|
||||
### Phase 0 — Current (Python prototype) ✅
|
||||
- [x] Basic chat server + web UI
|
||||
- [x] WebSocket SSH tunnel
|
||||
- [x] Nginx reverse proxy + ArvanCloud deployment
|
||||
- [x] ECDH + AES-GCM DMs (basic, no forward secrecy)
|
||||
- [x] Group chat with passwords
|
||||
- [x] PWA support
|
||||
- [x] File upload
|
||||
|
||||
### Phase 1 — Identity & Crypto Foundation (Rust)
|
||||
- [x] Rust project scaffold (cargo workspace: server, client, protocol, mule, wasm)
|
||||
- [x] Seed-based identity (Ed25519 + X25519 from 32-byte seed)
|
||||
- [x] BIP39 mnemonic generation and recovery
|
||||
- [x] Seed encryption at rest (Argon2 + ChaCha20-Poly1305, unlock once per session)
|
||||
- [x] Pre-key bundle generation and storage
|
||||
- [x] X3DH key exchange implementation
|
||||
- [x] Double Ratchet for 1:1 messaging (forward secrecy, out-of-order)
|
||||
- [x] Basic server: axum, sled DB, store-and-forward
|
||||
- [x] CLI TUI client (ratatui, real-time chat)
|
||||
- [x] Web client with WASM (same crypto as CLI, full interop)
|
||||
- [x] Group chat (server fan-out, per-member encryption)
|
||||
- [x] Aliases with TTL, recovery keys, reclamation
|
||||
- [x] Server auth (challenge-response, bearer tokens)
|
||||
- [x] OTP key replenishment
|
||||
- [x] Fetch-and-delete delivery
|
||||
- [x] 17 protocol tests
|
||||
- [x] WASM bridge for web↔CLI interop (same crypto on both clients)
|
||||
|
||||
### Phase 2 — Core Messaging
|
||||
- [ ] WebSocket real-time push (replace HTTP polling with instant delivery)
|
||||
- [ ] Delivery receipts (sent, delivered, read)
|
||||
- [ ] File transfer (chunked, encrypted)
|
||||
- [ ] Multi-device support (device list signed by identity key)
|
||||
- [ ] Sender Keys for group encryption (replace per-member fan-out)
|
||||
- [ ] Group management (kick, leave, key rotation)
|
||||
- [ ] Message ordering and deduplication
|
||||
- [ ] Ethereum-compatible identity (dual-curve: secp256k1 + X25519 from same BIP39 seed)
|
||||
- Fingerprint = Ethereum address (Keccak-256 of secp256k1 pubkey)
|
||||
- BIP44 paths: m/44'/60'/0'/0/0 (Ethereum), m/44'/1234'/0' (Warzone X25519)
|
||||
- MetaMask/Rabby wallet connect (sign challenge → derive session)
|
||||
- Hardware wallet support via existing secp256k1 (Ledger/Trezor)
|
||||
- ENS domain resolution (@vitalik.eth → 0xd8dA... → Warzone identity)
|
||||
- Crates: k256, tiny-keccak, ethers-rs/alloy for ENS resolution
|
||||
- Session key delegation from hardware wallet (sign once per 30 days)
|
||||
- [x] TUI client (ratatui)
|
||||
- [x] Web client (WASM)
|
||||
- [x] WebSocket real-time push
|
||||
- [x] Delivery receipts (sent/delivered/read)
|
||||
- [ ] Progressive Web App (PWA)
|
||||
- Web manifest with standalone display mode
|
||||
- Service worker for offline shell + notification support
|
||||
- Install prompt (Android Chrome "Add to Home Screen")
|
||||
- iOS: apple-mobile-web-app-capable meta tags
|
||||
- Push notifications via service worker (when tab unfocused)
|
||||
- Offline: show cached identity + "reconnecting" state
|
||||
- App icon (SVG, maskable)
|
||||
- [ ] Encrypted local message history & cloud backup
|
||||
- Messages encrypted at rest using key derived from seed (HKDF, info="warzone-history")
|
||||
- No extra password needed — if you have your seed, you can read your history
|
||||
- Optional passphrase for additional protection (double encryption)
|
||||
- Browser: encrypted blob in IndexedDB, export as file
|
||||
- CLI: encrypted sled DB (already has seed-encrypted keystore)
|
||||
- Cloud backup targets: S3-compatible, Google Drive, WebDAV
|
||||
- Backup format: encrypted archive (ChaCha20-Poly1305), versioned, deduplicated
|
||||
- Restore: import backup + provide seed → decrypt and merge history
|
||||
- Sync: periodic incremental backup (new messages since last backup)
|
||||
- Privacy: backup provider sees only encrypted blobs, no metadata
|
||||
|
||||
### Phase 3 — Federation & Key Transparency
|
||||
- [ ] DNS TXT record format specification (server discovery + user key transparency)
|
||||
- [ ] User self-signed key publication to DNS (`_wz._id.<hash>.domain`)
|
||||
- [ ] Key verification: server response vs DNS record cross-check
|
||||
- [ ] Server-to-server mutual TLS authentication
|
||||
- [ ] Federated message delivery
|
||||
- [ ] Server key pinning (TOFU)
|
||||
- [ ] Federated pre-key bundle fetching
|
||||
- [ ] Gossip-based peer discovery fallback
|
||||
- [ ] Hard-coded peer list for DNS-free operation
|
||||
|
||||
### Phase 4 — Warzone Delivery
|
||||
- [ ] Mule protocol specification
|
||||
- [ ] Mule authentication and authorization
|
||||
- [ ] Message pickup with capacity declaration
|
||||
- [ ] Delivery receipt enforcement
|
||||
- [ ] Outer encryption layer (hide metadata from mule)
|
||||
- [ ] Bundle compression (zstd)
|
||||
- [ ] Partial sync / resume
|
||||
- [ ] Priority levels
|
||||
- [ ] Mule CLI binary
|
||||
|
||||
### Phase 5 — Transport Fallbacks
|
||||
- [ ] Bluetooth mule transfer (phone-to-phone, phone-to-server)
|
||||
- [ ] LoRa transport layer (low bandwidth, long range, last-resort)
|
||||
- [ ] mDNS / LAN discovery for local mesh
|
||||
- [ ] Wi-Fi Direct for nearby device sync
|
||||
|
||||
### Phase 6 — Metadata Protection (Optional Layer)
|
||||
- [ ] Onion routing between federated servers (opt-in, requires good connectivity)
|
||||
- [ ] Padding and traffic shaping to resist traffic analysis
|
||||
- [ ] Sealed sender (server doesn't know who sent a message, only who receives)
|
||||
|
||||
### Phase 7 — Polish & Operations
|
||||
- [ ] ntfy integration for push notifications
|
||||
- [ ] DoH for DNS resolution in censored networks
|
||||
- [ ] Admin CLI (manage users, mules, federation)
|
||||
- [ ] Monitoring and health checks
|
||||
- [ ] Rate limiting and abuse prevention
|
||||
- [ ] Audit logging
|
||||
- [ ] Server-at-rest encryption (optional, manual key on boot)
|
||||
- [ ] Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
|
||||
- [ ] Documentation and protocol specification
|
||||
|
||||
---
|
||||
|
||||
## Resolved Decisions
|
||||
|
||||
| Question | Decision | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| MLS vs Sender Keys | **Sender Keys** (groups ≤ 50) | Simpler, sufficient for target group sizes. MLS revisited if needed later. |
|
||||
| Metadata protection | **Optional onion layer** | Opt-in when connectivity allows. Not a blocker for core functionality. Sealed sender as a lighter alternative first. |
|
||||
| Deniability | **Deniability by default** (Signal model) | Safety-first for users in hostile environments. Non-repudiation can be added as opt-in per-conversation later. |
|
||||
| Server-at-rest encryption | **Optional, not in core** | Nice to have. Implement as a flag: `--encrypt-db` with passphrase on boot. E2E already protects message content. |
|
||||
| Incentives / tokenization | **Not in scope** | This is an organizational/military tool. Participants cooperate by mandate, not incentive. |
|
||||
| Transport fallbacks | **Bluetooth + LoRa** | Mules use Bluetooth for device-to-device. LoRa for extreme last-resort (low bandwidth but km range). LoRa is not Phase 1. |
|
||||
| Key transparency | **DNS TXT records** | Each user self-signs their pubkey in a DNS TXT record. Server can't MITM because it can't forge the self-signature. Integrated with federation DNS in Phase 3. |
|
||||
| Multi-device ratchet | **Per-device sessions** | Each device maintains its own Double Ratchet session with each contact (Signal's approach). Cross-device history sync via encrypted device-to-device channel using shared seed. |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **LoRa investment**: LoRa has ~250 byte payload limit. Emergency-only (receipts + short text) or a real feature? Not Phase 1 either way — but the compact binary format should be designed early so the message layer doesn't assume JSON everywhere.
|
||||
|
||||
2. **Legal**: E2E encryption with mule delivery designed for warzone use has significant legal implications in many jurisdictions. Needs legal review before deployment.
|
||||
|
||||
3. **Sealed sender vs onion routing**: Sealed sender (Signal's approach — server knows recipient but not sender) is lighter than full onion routing. Plan: sealed sender first as the default metadata protection, full onion routing as Phase 6 upgrade for when connectivity allows it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Transport Layer Architecture
|
||||
|
||||
The protocol is transport-agnostic. The message format is the same regardless of how it travels. Transports are pluggable:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
│ (Signal Protocol, Message Routing, Queue) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Transport Abstraction │
|
||||
│ trait Transport { │
|
||||
│ async fn send(&self, endpoint, blob); │
|
||||
│ async fn recv(&self) -> blob; │
|
||||
│ } │
|
||||
├──────┬──────┬──────┬──────┬────────┬────────┤
|
||||
│ HTTPS│ WS │ BT │ LoRa │Wi-Fi │ USB │
|
||||
│ │ │ │ │Direct │ (file) │
|
||||
└──────┴──────┴──────┴──────┴────────┴────────┘
|
||||
```
|
||||
|
||||
### HTTPS (Primary)
|
||||
- Standard server-to-server and client-to-server
|
||||
- TLS 1.3, certificate pinning
|
||||
- HTTP/2 for multiplexing
|
||||
- SSE or WebSocket for real-time push
|
||||
|
||||
### Bluetooth (Mule + Nearby)
|
||||
- BLE for discovery, Bluetooth Classic for data transfer
|
||||
- Range: ~10-100m
|
||||
- Bandwidth: ~2 Mbps practical
|
||||
- Use case: mule syncs with server/client in proximity
|
||||
- Protocol: RFCOMM socket, same message blobs as HTTPS
|
||||
|
||||
### LoRa (Last Resort)
|
||||
- Range: 2-15 km (line of sight), 1-5 km urban
|
||||
- Bandwidth: 0.3-50 kbps
|
||||
- Payload: ~250 bytes per packet
|
||||
- Use case: delivery receipts, short text, presence beacons
|
||||
- NOT for files or media — text-only, heavily compressed
|
||||
- Message format: compact binary (not JSON)
|
||||
|
||||
```
|
||||
LoRa packet (250 bytes max):
|
||||
[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 (AES-GCM, ~200 chars of text)
|
||||
```
|
||||
|
||||
### Wi-Fi Direct (Nearby Mesh)
|
||||
- Range: ~200m
|
||||
- Bandwidth: ~250 Mbps
|
||||
- Use case: local group sync when no internet, ad-hoc mesh
|
||||
- Devices form a local group, sync message queues peer-to-peer
|
||||
|
||||
### USB / File (Sneakernet)
|
||||
- Export message queue to encrypted file
|
||||
- Copy to USB drive
|
||||
- Import on destination machine
|
||||
- Same as mule protocol but manual file transfer
|
||||
- `warzone export --since 24h --to /mnt/usb/messages.wz`
|
||||
- `warzone import /mnt/usb/messages.wz`
|
||||
710
chat.py
710
chat.py
@@ -25,7 +25,7 @@ import html
|
||||
import urllib.parse
|
||||
|
||||
PORT = 9999
|
||||
VERSION = "10"
|
||||
VERSION = "14"
|
||||
TUNNEL_TARGETS = {
|
||||
"parspack": ("185.208.174.152", 22),
|
||||
"mequ": ("188.213.68.133", 2022),
|
||||
@@ -36,9 +36,32 @@ MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
|
||||
|
||||
# ── Server ──────────────────────────────────────────────────────────────
|
||||
|
||||
clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
|
||||
sse_queues: list[asyncio.Queue] = [] # web clients
|
||||
history: list[dict] = []
|
||||
# E2E encryption: public key registry (username -> JWK public key JSON string)
|
||||
KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "keys.json")
|
||||
|
||||
|
||||
def load_keys() -> dict[str, str]:
|
||||
"""Load user keys from disk."""
|
||||
try:
|
||||
with open(KEYS_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_keys():
|
||||
"""Persist user keys to disk."""
|
||||
try:
|
||||
with open(KEYS_FILE, "w") as f:
|
||||
json.dump(user_keys, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
user_keys: dict[str, str] = load_keys()
|
||||
# DM routing: username -> list of queues for SSE delivery
|
||||
dm_targets: dict[str, list] = {}
|
||||
|
||||
uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes (insertion order)
|
||||
total_file_bytes = 0
|
||||
|
||||
@@ -53,31 +76,52 @@ def store_file(file_id: str, data: bytes):
|
||||
total_file_bytes -= len(uploaded_files.pop(oldest_id))
|
||||
|
||||
|
||||
class Group:
|
||||
"""A chat group with its own history, clients, and optional password."""
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.password: str | None = None
|
||||
self.history: list[dict] = []
|
||||
self.clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
|
||||
self.sse_queues: list[asyncio.Queue] = []
|
||||
|
||||
async def broadcast(msg: dict):
|
||||
history.append(msg)
|
||||
async def broadcast(self, msg: dict):
|
||||
self.history.append(msg)
|
||||
line = json.dumps(msg) + "\n"
|
||||
|
||||
# TCP clients
|
||||
dead = []
|
||||
for w in clients:
|
||||
for w in self.clients:
|
||||
try:
|
||||
w.write(line.encode())
|
||||
await w.drain()
|
||||
except Exception:
|
||||
dead.append(w)
|
||||
for w in dead:
|
||||
clients.pop(w, None)
|
||||
self.clients.pop(w, None)
|
||||
|
||||
# SSE web clients
|
||||
dead_q = []
|
||||
for q in sse_queues:
|
||||
for q in self.sse_queues:
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except Exception:
|
||||
dead_q.append(q)
|
||||
for q in dead_q:
|
||||
sse_queues.remove(q)
|
||||
self.sse_queues.remove(q)
|
||||
|
||||
|
||||
# All groups keyed by name. Auto-created on first access.
|
||||
groups: dict[str, Group] = {}
|
||||
DEFAULT_GROUP = "lobby"
|
||||
|
||||
|
||||
def get_group(name: str) -> Group:
|
||||
"""Get or create a group by name."""
|
||||
name = name.lower().strip()
|
||||
if not name:
|
||||
name = DEFAULT_GROUP
|
||||
if name not in groups:
|
||||
groups[name] = Group(name)
|
||||
return groups[name]
|
||||
|
||||
|
||||
# ── HTML / JS chat page ────────────────────────────────────────────────
|
||||
@@ -86,62 +130,123 @@ CHAT_HTML = r"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Chat">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<title>Chat</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Courier New', monospace;
|
||||
display: flex; flex-direction: column; height: 100vh; }
|
||||
#messages { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.msg { padding: 3px 0; white-space: pre-wrap; word-wrap: break-word; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body { background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, 'Courier New', monospace;
|
||||
display: flex; flex-direction: column; height: 100vh; height: 100dvh;
|
||||
padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
|
||||
#messages { flex: 1; overflow-y: auto; padding: 10px; -webkit-overflow-scrolling: touch; }
|
||||
.msg { padding: 3px 0; white-space: pre-wrap; word-wrap: break-word; font-size: 14px; }
|
||||
.msg code { background: #2a2a4a; padding: 1px 5px; border-radius: 3px; color: #f8c555; }
|
||||
.msg pre { background: #12122a; border: 1px solid #333; border-radius: 4px;
|
||||
padding: 8px; margin: 4px 0; overflow-x: auto; }
|
||||
padding: 8px; margin: 4px 0; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.msg pre code { background: none; padding: 0; color: #e0e0e0; }
|
||||
.msg strong { color: #fff; }
|
||||
.msg em { color: #ccc; }
|
||||
.msg a.auto-link { color: #67c7eb; }
|
||||
.ts { color: #666; }
|
||||
.sys { color: #5e9ca0; font-style: italic; }
|
||||
.dm-hint { color: #555; font-size: 0.8em; display: block; margin-top: 2px; }
|
||||
.file-link { display: inline-block; background: #0f3460; border: 1px solid #444;
|
||||
padding: 4px 10px; border-radius: 4px; margin: 2px 0; color: #67c7eb;
|
||||
padding: 6px 12px; border-radius: 4px; margin: 2px 0; color: #67c7eb;
|
||||
text-decoration: none; }
|
||||
.file-link:hover { background: #1a4a80; }
|
||||
#bottom { display: flex; padding: 8px; gap: 8px; border-top: 1px solid #333;
|
||||
.file-link:hover, .file-link:active { background: #1a4a80; }
|
||||
#header { display: flex; padding: 6px 10px; gap: 6px; background: #16213e;
|
||||
border-bottom: 1px solid #333; align-items: center; }
|
||||
#name { flex: 0 0 auto; width: 80px; padding: 8px; background: #0f3460; border: 1px solid #444;
|
||||
color: #e0e0e0; border-radius: 4px; font-size: 14px; }
|
||||
#header-info { flex: 1; font-size: 0.7em; text-align: right; color: #8899aa; }
|
||||
#header-info code { background: #0f3460; color: #67c7eb; padding: 1px 5px; border-radius: 3px;
|
||||
font-size: 1em; }
|
||||
#bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #333;
|
||||
background: #16213e; align-items: flex-end; }
|
||||
#name { width: 100px; padding: 8px; background: #0f3460; border: 1px solid #444;
|
||||
color: #e0e0e0; border-radius: 4px; align-self: flex-end; }
|
||||
#input { flex: 1; padding: 8px; background: #0f3460; border: 1px solid #444;
|
||||
color: #e0e0e0; border-radius: 4px; resize: none; min-height: 38px;
|
||||
max-height: 200px; font-family: inherit; font-size: inherit; line-height: 1.4; }
|
||||
#send { padding: 8px 16px; background: #e94560; border: none; color: #fff;
|
||||
border-radius: 4px; cursor: pointer; align-self: flex-end; }
|
||||
#send:hover { background: #c73e54; }
|
||||
#file-btn { padding: 8px 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0;
|
||||
border-radius: 4px; cursor: pointer; align-self: flex-end; font-size: 1.1em; }
|
||||
#file-btn:hover { background: #1a4a80; }
|
||||
#input { flex: 1; padding: 10px; background: #0f3460; border: 1px solid #444;
|
||||
color: #e0e0e0; border-radius: 20px; resize: none; min-height: 40px;
|
||||
max-height: 120px; font-family: inherit; font-size: 16px; line-height: 1.4; }
|
||||
#send { padding: 10px 16px; background: #e94560; border: none; color: #fff;
|
||||
border-radius: 20px; cursor: pointer; align-self: flex-end; font-size: 14px;
|
||||
min-height: 40px; }
|
||||
#send:hover, #send:active { background: #c73e54; }
|
||||
#file-btn { padding: 10px; background: #0f3460; border: 1px solid #444; color: #e0e0e0;
|
||||
border-radius: 50%; cursor: pointer; align-self: flex-end; font-size: 1.1em;
|
||||
min-width: 40px; min-height: 40px; text-align: center; line-height: 20px; }
|
||||
#file-btn:hover, #file-btn:active { background: #1a4a80; }
|
||||
#file-input { display: none; }
|
||||
.hint { color: #555; font-size: 0.75em; padding: 2px 12px; }
|
||||
#install-bar { display: none; padding: 8px 12px; background: #0f3460; text-align: center;
|
||||
border-bottom: 1px solid #333; }
|
||||
#install-bar button { background: #e94560; border: none; color: #fff; padding: 6px 16px;
|
||||
border-radius: 4px; cursor: pointer; margin: 0 4px; }
|
||||
#install-bar .dismiss { background: transparent; color: #666; }
|
||||
#pw-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.8);
|
||||
z-index:100; align-items:center; justify-content:center; }
|
||||
#pw-overlay.show { display:flex; }
|
||||
#pw-box { background:#16213e; border:1px solid #444; border-radius:8px; padding:24px;
|
||||
text-align:center; max-width:300px; width:90%; }
|
||||
#pw-box h3 { margin-bottom:12px; color:#e0e0e0; }
|
||||
#pw-box input { width:100%; padding:10px; background:#0f3460; border:1px solid #444;
|
||||
color:#e0e0e0; border-radius:4px; margin-bottom:10px; font-size:16px; }
|
||||
#pw-box button { padding:8px 20px; background:#e94560; border:none; color:#fff;
|
||||
border-radius:4px; cursor:pointer; }
|
||||
#pw-box .pw-err { color:#e94560; font-size:0.85em; margin-bottom:8px; display:none; }
|
||||
#group-tag { background:#e94560; color:#fff; padding:2px 8px; border-radius:10px;
|
||||
font-size:0.75em; margin-left:6px; }
|
||||
@media (max-width: 500px) {
|
||||
.msg { font-size: 13px; }
|
||||
.ts { font-size: 11px; }
|
||||
#input { font-size: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="install-bar">
|
||||
Install as app for notifications & fullscreen
|
||||
<button id="install-btn">Install</button>
|
||||
<button class="dismiss" id="install-dismiss">Later</button>
|
||||
</div>
|
||||
<div id="pw-overlay">
|
||||
<div id="pw-box">
|
||||
<h3>This group is password protected</h3>
|
||||
<div class="pw-err" id="pw-err">Wrong password</div>
|
||||
<input type="password" id="pw-input" placeholder="Enter password…" autocomplete="off">
|
||||
<button id="pw-btn">Join</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="header">
|
||||
<input id="name" placeholder="Name" value="" autocomplete="off">
|
||||
<span id="group-tag"></span>
|
||||
<span id="header-info"><code>/dm @user msg</code> <code>/r reply</code> <code>/users</code> <code>/setpass</code> <code>/color</code></span>
|
||||
</div>
|
||||
<div id="messages"></div>
|
||||
<div class="hint">Shift+Enter for newline · Enter to send</div>
|
||||
<div id="bottom">
|
||||
<input id="name" placeholder="Name" value="">
|
||||
<label id="file-btn" title="Upload file">📎<input type="file" id="file-input"></label>
|
||||
<textarea id="input" placeholder="Type a message…" rows="1" autofocus
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
<label id="file-btn" title="Upload file">📎<input type="file" id="file-input"></label>
|
||||
<button id="send">Send</button>
|
||||
<button id="send">▶</button>
|
||||
</div>
|
||||
<script>
|
||||
const GROUP = '%%GROUP%%';
|
||||
const HAS_PASSWORD = %%HAS_PASSWORD%%;
|
||||
const BASE = '/group/' + GROUP;
|
||||
|
||||
const $msg = document.getElementById('messages');
|
||||
const $input = document.getElementById('input');
|
||||
const $name = document.getElementById('name');
|
||||
const $send = document.getElementById('send');
|
||||
const $file = document.getElementById('file-input');
|
||||
|
||||
$name.value = 'user' + Math.floor(Math.random() * 1000);
|
||||
document.getElementById('group-tag').textContent = GROUP;
|
||||
document.title = GROUP + ' - Chat';
|
||||
|
||||
$name.value = localStorage.getItem('chat-name') || ('user' + Math.floor(Math.random() * 1000));
|
||||
|
||||
const USER_COLORS = [
|
||||
'#e6a23c', '#f56c9d', '#67c7eb', '#b39ddb',
|
||||
@@ -226,15 +331,64 @@ function addMsg(data) {
|
||||
function send() {
|
||||
const text = $input.value.trimEnd();
|
||||
const name = $name.value.trim() || 'anon';
|
||||
localStorage.setItem('chat-name', name);
|
||||
if (!text) return;
|
||||
// Local commands
|
||||
const dmMatch = text.match(/^\/dm\s+@?(\S+)\s+([\s\S]+)/);
|
||||
if (dmMatch) {
|
||||
lastDmPeer = dmMatch[1];
|
||||
encryptAndSendDM(dmMatch[1], dmMatch[2]);
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
const replyMatch = text.match(/^\/(?:reply|r)\s+([\s\S]+)/);
|
||||
if (replyMatch) {
|
||||
if (!lastDmPeer) {
|
||||
addMsg({ts:Date.now()/1000, user:'***', text:'No one to reply to. Use /dm @user first.'});
|
||||
} else {
|
||||
encryptAndSendDM(lastDmPeer, replyMatch[1]);
|
||||
}
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
if (text === '/users' || text === '/online') {
|
||||
fetch('/keys').then(r => r.json()).then(users => {
|
||||
addMsg({ts:Date.now()/1000, user:'***', text:'Users with keys: ' + users.join(', ')});
|
||||
});
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
if (text === '/colors' || text === '/color') {
|
||||
reshuffleColors();
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
fetch('/chat/send', {
|
||||
if (text.startsWith('/setpass ')) {
|
||||
const pw = text.substring(9).trim();
|
||||
fetch(BASE + '/setpass', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'password=' + encodeURIComponent(pw)
|
||||
}).then(() => addMsg({ts:Date.now()/1000, user:'***', text:'Password set for this group.'}));
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
if (text === '/clearpass') {
|
||||
fetch(BASE + '/setpass', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'password='
|
||||
}).then(() => addMsg({ts:Date.now()/1000, user:'***', text:'Password cleared.'}));
|
||||
$input.value = '';
|
||||
$input.style.height = 'auto';
|
||||
return;
|
||||
}
|
||||
fetch(BASE + '/send', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'name=' + encodeURIComponent(name) + '&text=' + encodeURIComponent(text)
|
||||
@@ -265,7 +419,7 @@ $file.onchange = function() {
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('file', f);
|
||||
fetch('/chat/upload', { method: 'POST', body: fd });
|
||||
fetch(BASE + '/upload', { method: 'POST', body: fd });
|
||||
this.value = '';
|
||||
};
|
||||
|
||||
@@ -304,19 +458,273 @@ window.addEventListener('focus', function() {
|
||||
document.title = baseTitle;
|
||||
});
|
||||
|
||||
// SSE
|
||||
const es = new EventSource('/chat/events');
|
||||
es.onmessage = function(e) {
|
||||
// Password gate
|
||||
let sessionPass = sessionStorage.getItem('pw-' + GROUP) || '';
|
||||
|
||||
function startSSE() {
|
||||
const myName = $name.value.trim() || 'anon';
|
||||
const params = new URLSearchParams();
|
||||
if (sessionPass) params.set('password', sessionPass);
|
||||
params.set('name', myName);
|
||||
const url = BASE + '/events?' + params.toString();
|
||||
const es = new EventSource(url);
|
||||
es.onmessage = function(e) {
|
||||
// Check for auth error
|
||||
const data = JSON.parse(e.data);
|
||||
if (data._auth === 'fail') {
|
||||
es.close();
|
||||
showPasswordPrompt();
|
||||
return;
|
||||
}
|
||||
if (data.dm && data.encrypted) {
|
||||
handleEncryptedDM(data);
|
||||
} else {
|
||||
addMsg(data);
|
||||
}
|
||||
notify(data);
|
||||
};
|
||||
es.onerror = function() {
|
||||
addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'});
|
||||
};
|
||||
}
|
||||
|
||||
function showPasswordPrompt() {
|
||||
document.getElementById('pw-overlay').classList.add('show');
|
||||
document.getElementById('pw-input').focus();
|
||||
}
|
||||
|
||||
document.getElementById('pw-btn').onclick = function() {
|
||||
sessionPass = document.getElementById('pw-input').value;
|
||||
sessionStorage.setItem('pw-' + GROUP, sessionPass);
|
||||
document.getElementById('pw-overlay').classList.remove('show');
|
||||
startSSE();
|
||||
};
|
||||
es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); };
|
||||
document.getElementById('pw-input').onkeydown = function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('pw-btn').click();
|
||||
};
|
||||
|
||||
// ── E2E Encrypted DMs (ECDH + AES-256-GCM via Web Crypto) ──
|
||||
|
||||
let myKeyPair = null;
|
||||
let lastDmPeer = null; // for /reply
|
||||
let myPubJwk = null;
|
||||
const derivedKeys = {}; // cache: username -> CryptoKey (AES)
|
||||
|
||||
async function initCrypto() {
|
||||
// Try to load persisted keys from localStorage
|
||||
const savedPriv = localStorage.getItem('chat-key-priv');
|
||||
const savedPub = localStorage.getItem('chat-key-pub');
|
||||
if (savedPriv && savedPub) {
|
||||
try {
|
||||
const privJwk = JSON.parse(savedPriv);
|
||||
myPubJwk = JSON.parse(savedPub);
|
||||
const privKey = await crypto.subtle.importKey(
|
||||
'jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
|
||||
);
|
||||
const pubKey = await crypto.subtle.importKey(
|
||||
'jwk', myPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, []
|
||||
);
|
||||
myKeyPair = { privateKey: privKey, publicKey: pubKey };
|
||||
} catch(e) {
|
||||
// Corrupted keys, regenerate
|
||||
localStorage.removeItem('chat-key-priv');
|
||||
localStorage.removeItem('chat-key-pub');
|
||||
return initCrypto();
|
||||
}
|
||||
} else {
|
||||
// Generate new keys and persist
|
||||
myKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
|
||||
);
|
||||
myPubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
|
||||
const privJwk = await crypto.subtle.exportKey('jwk', myKeyPair.privateKey);
|
||||
localStorage.setItem('chat-key-priv', JSON.stringify(privJwk));
|
||||
localStorage.setItem('chat-key-pub', JSON.stringify(myPubJwk));
|
||||
}
|
||||
// Always register public key with server (re-registers on reconnect)
|
||||
const myName = $name.value.trim() || 'anon';
|
||||
fetch('/keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'name=' + encodeURIComponent(myName) + '&key=' + encodeURIComponent(JSON.stringify(myPubJwk))
|
||||
});
|
||||
}
|
||||
|
||||
// Re-register key when name changes
|
||||
$name.addEventListener('change', function() {
|
||||
localStorage.setItem('chat-name', this.value);
|
||||
if (myPubJwk) {
|
||||
fetch('/keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'name=' + encodeURIComponent(this.value.trim()) + '&key=' + encodeURIComponent(JSON.stringify(myPubJwk))
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function deriveAESKey(theirPubJwk) {
|
||||
const theirPub = await crypto.subtle.importKey(
|
||||
'jwk', theirPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: theirPub }, myKeyPair.privateKey, 256
|
||||
);
|
||||
return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
async function getAESKey(username) {
|
||||
if (derivedKeys[username]) return derivedKeys[username];
|
||||
const resp = await fetch('/keys/' + encodeURIComponent(username));
|
||||
if (!resp.ok) return null;
|
||||
const jwk = JSON.parse(await resp.text());
|
||||
const key = await deriveAESKey(jwk);
|
||||
derivedKeys[username] = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
async function encryptAndSendDM(recipient, plaintext) {
|
||||
const aesKey = await getAESKey(recipient);
|
||||
if (!aesKey) {
|
||||
addMsg({ts: Date.now()/1000, user: '***', text: 'User "' + recipient + '" has no key registered. They must be online.'});
|
||||
return;
|
||||
}
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce }, aesKey, new TextEncoder().encode(plaintext)
|
||||
);
|
||||
const myName = $name.value.trim() || 'anon';
|
||||
fetch('/dm', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'from=' + encodeURIComponent(myName)
|
||||
+ '&to=' + encodeURIComponent(recipient)
|
||||
+ '&encrypted=' + encodeURIComponent(btoa(String.fromCharCode(...new Uint8Array(enc))))
|
||||
+ '&nonce=' + encodeURIComponent(btoa(String.fromCharCode(...nonce)))
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEncryptedDM(data) {
|
||||
const myName = $name.value.trim();
|
||||
// Only decrypt if we are sender or recipient
|
||||
if (data.to !== myName && data.user !== myName) return;
|
||||
const otherUser = data.user === myName ? data.to : data.user;
|
||||
// Update reply target when someone DMs us
|
||||
if (data.user !== myName) lastDmPeer = data.user;
|
||||
try {
|
||||
const aesKey = await getAESKey(otherUser);
|
||||
if (!aesKey) throw new Error('no key');
|
||||
const ciphertext = Uint8Array.from(atob(data.encrypted), c => c.charCodeAt(0));
|
||||
const nonce = Uint8Array.from(atob(data.nonce), c => c.charCodeAt(0));
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ciphertext);
|
||||
const text = new TextDecoder().decode(plain);
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const t = new Date(data.ts * 1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
||||
const arrow = data.user === myName ? '→ ' + esc(data.to) : '← ' + esc(data.user);
|
||||
const replyTo = data.user === myName ? data.to : data.user;
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span class="dm-tag" data-reply="' + esc(replyTo) + '" style="color:#ff6b9d;cursor:pointer" title="Click to reply">🔒 DM ' + arrow + '</span>: ' + renderMd(text);
|
||||
d.querySelector('.dm-tag').onclick = function() {
|
||||
$input.value = '/dm @' + this.dataset.reply + ' ';
|
||||
$input.focus();
|
||||
};
|
||||
$msg.appendChild(d);
|
||||
$msg.scrollTop = $msg.scrollHeight;
|
||||
} catch(e) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const replyTo = data.user;
|
||||
const t = new Date(data.ts * 1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span class="dm-tag" data-reply="' + esc(replyTo) + '" style="color:#ff6b9d;cursor:pointer" title="Click to reply">🔒 DM from ' + esc(data.user) + '</span>: <em>[cannot decrypt]</em>';
|
||||
d.querySelector('.dm-tag').onclick = function() {
|
||||
$input.value = '/dm @' + this.dataset.reply + ' ';
|
||||
$input.focus();
|
||||
};
|
||||
$msg.appendChild(d);
|
||||
$msg.scrollTop = $msg.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
initCrypto();
|
||||
|
||||
// Check password requirement and start
|
||||
if (HAS_PASSWORD && !sessionPass) {
|
||||
showPasswordPrompt();
|
||||
} else {
|
||||
startSSE();
|
||||
}
|
||||
|
||||
// PWA install prompt
|
||||
let deferredPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function(e) {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
document.getElementById('install-bar').style.display = 'block';
|
||||
});
|
||||
document.getElementById('install-btn').onclick = function() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(function() { deferredPrompt = null; });
|
||||
}
|
||||
document.getElementById('install-bar').style.display = 'none';
|
||||
};
|
||||
document.getElementById('install-dismiss').onclick = function() {
|
||||
document.getElementById('install-bar').style.display = 'none';
|
||||
};
|
||||
|
||||
// Service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function(){});
|
||||
}
|
||||
|
||||
// Mobile
|
||||
window.visualViewport && window.visualViewport.addEventListener('resize', function() {
|
||||
$msg.scrollTop = $msg.scrollHeight;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
PWA_MANIFEST = json.dumps({
|
||||
"name": "Chat",
|
||||
"short_name": "Chat",
|
||||
"description": "Minimal multi-user chat",
|
||||
"start_url": "/chat",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#1a1a2e",
|
||||
"icons": [
|
||||
{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}
|
||||
]
|
||||
})
|
||||
|
||||
# Minimal SVG icon (chat bubble)
|
||||
PWA_ICON = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="100" fill="#1a1a2e"/>
|
||||
<path d="M128 140h256c22 0 40 18 40 40v152c0 22-18 40-40 40H210l-70 52v-52h-12c-22 0-40-18-40-40V180c0-22 18-40 40-40z" fill="#e94560"/>
|
||||
<circle cx="200" cy="256" r="18" fill="#fff"/>
|
||||
<circle cx="256" cy="256" r="18" fill="#fff"/>
|
||||
<circle cx="312" cy="256" r="18" fill="#fff"/>
|
||||
</svg>"""
|
||||
|
||||
SERVICE_WORKER = """
|
||||
const CACHE = 'chat-v1';
|
||||
self.addEventListener('install', e => { self.skipWaiting(); });
|
||||
self.addEventListener('activate', e => { e.waitUntil(clients.claim()); });
|
||||
self.addEventListener('fetch', e => {
|
||||
// Let all requests go to network (chat is real-time, caching would break it)
|
||||
// But cache the shell for offline "you're offline" experience
|
||||
if (e.request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(() => new Response(
|
||||
'<html><body style="background:#1a1a2e;color:#e0e0e0;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh"><h2>Offline - connect to the internet</h2></body></html>',
|
||||
{headers: {'Content-Type': 'text/html'}}
|
||||
))
|
||||
);
|
||||
}
|
||||
});
|
||||
"""
|
||||
|
||||
# ── Multipart parser (minimal, for file uploads) ───────────────────────
|
||||
|
||||
def parse_multipart(body: bytes, boundary: str) -> dict:
|
||||
@@ -455,6 +863,40 @@ async def handle_ws_tunnel(ws_reader, ws_writer, target):
|
||||
async def handle_http(reader, writer, first_line):
|
||||
method, path, headers, body = await parse_http_request(reader, first_line)
|
||||
|
||||
# PWA assets
|
||||
if method == "GET" and path == "/manifest.json":
|
||||
resp = PWA_MANIFEST.encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: application/manifest+json\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
if method == "GET" and path == "/icon.svg":
|
||||
resp = PWA_ICON.encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: image/svg+xml\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
if method == "GET" and path == "/sw.js":
|
||||
resp = SERVICE_WORKER.encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: application/javascript\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /version
|
||||
if method == "GET" and path == "/version":
|
||||
resp = json.dumps({"version": VERSION}).encode()
|
||||
@@ -487,9 +929,24 @@ async def handle_http(reader, writer, first_line):
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET / or /chat — serve the web UI
|
||||
# GET / or /chat → redirect to /group/lobby
|
||||
if method == "GET" and path in ("/", "/chat", "/chat/"):
|
||||
resp = CHAT_HTML.encode()
|
||||
writer.write(b"HTTP/1.1 302 Found\r\nLocation: /group/lobby\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# ── Group routes: /group/<name>[/action] ──
|
||||
if path.startswith("/group/"):
|
||||
parts = path[7:].strip("/").split("/", 1) # strip "/group/"
|
||||
group_name = urllib.parse.unquote(parts[0]) if parts[0] else DEFAULT_GROUP
|
||||
action = parts[1] if len(parts) > 1 else ""
|
||||
grp = get_group(group_name)
|
||||
|
||||
# GET /group/<name> — serve the web UI
|
||||
if method == "GET" and action == "":
|
||||
has_pw = "true" if grp.password else "false"
|
||||
resp = CHAT_HTML.replace("%%GROUP%%", group_name).replace("%%HAS_PASSWORD%%", has_pw).encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/html; charset=utf-8\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
@@ -499,8 +956,25 @@ async def handle_http(reader, writer, first_line):
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /chat/events — SSE stream
|
||||
if method == "GET" and path == "/chat/events":
|
||||
# GET /group/<name>/events — SSE stream
|
||||
if method == "GET" and action.startswith("events"):
|
||||
# Check password
|
||||
query = ""
|
||||
if "?" in action:
|
||||
query = action.split("?", 1)[1]
|
||||
qs = urllib.parse.parse_qs(query)
|
||||
pw = qs.get("password", [""])[0]
|
||||
if grp.password and pw != grp.password:
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/event-stream\r\n")
|
||||
writer.write(b"Cache-Control: no-cache\r\n")
|
||||
writer.write(b"X-Accel-Buffering: no\r\n")
|
||||
writer.write(b"\r\n")
|
||||
writer.write(f"data: {json.dumps({'_auth': 'fail'})}\n\n".encode())
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: text/event-stream\r\n")
|
||||
writer.write(b"Cache-Control: no-cache\r\n")
|
||||
@@ -510,12 +984,16 @@ async def handle_http(reader, writer, first_line):
|
||||
writer.write(b"\r\n")
|
||||
await writer.drain()
|
||||
|
||||
for msg in history:
|
||||
for msg in grp.history:
|
||||
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
|
||||
await writer.drain()
|
||||
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
sse_queues.append(q)
|
||||
grp.sse_queues.append(q)
|
||||
# Register for DM delivery
|
||||
dm_name = qs.get("name", [""])[0]
|
||||
if dm_name:
|
||||
dm_targets.setdefault(dm_name, []).append(q)
|
||||
try:
|
||||
while True:
|
||||
msg = await q.get()
|
||||
@@ -524,25 +1002,32 @@ async def handle_http(reader, writer, first_line):
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if q in sse_queues:
|
||||
sse_queues.remove(q)
|
||||
if q in grp.sse_queues:
|
||||
grp.sse_queues.remove(q)
|
||||
if dm_name and dm_name in dm_targets:
|
||||
try:
|
||||
dm_targets[dm_name].remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
if not dm_targets[dm_name]:
|
||||
del dm_targets[dm_name]
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /chat/send — send a message (supports multiline via JSON text field)
|
||||
if method == "POST" and path == "/chat/send":
|
||||
# POST /group/<name>/send
|
||||
if method == "POST" and action == "send":
|
||||
params = urllib.parse.parse_qs(body.decode())
|
||||
name = params.get("name", ["anon"])[0]
|
||||
text = params.get("text", [""])[0].strip()
|
||||
if text:
|
||||
await broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
await grp.broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /chat/upload — file upload (multipart/form-data)
|
||||
if method == "POST" and path == "/chat/upload":
|
||||
# POST /group/<name>/upload
|
||||
if method == "POST" and action == "upload":
|
||||
ct = headers.get("content-type", "")
|
||||
if "multipart/form-data" in ct and "boundary=" in ct:
|
||||
boundary = ct.split("boundary=")[1].strip()
|
||||
@@ -557,7 +1042,7 @@ async def handle_http(reader, writer, first_line):
|
||||
if len(file_data) <= MAX_FILE_SIZE:
|
||||
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
|
||||
store_file(file_id, file_data)
|
||||
await broadcast({
|
||||
await grp.broadcast({
|
||||
"ts": time.time(), "user": name,
|
||||
"text": f"[file: {filename}]",
|
||||
"file_id": file_id, "filename": filename,
|
||||
@@ -568,6 +1053,90 @@ async def handle_http(reader, writer, first_line):
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /group/<name>/setpass
|
||||
if method == "POST" and action == "setpass":
|
||||
params = urllib.parse.parse_qs(body.decode(), keep_blank_values=True)
|
||||
pw = params.get("password", [""])[0].strip()
|
||||
grp.password = pw if pw else None
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# ── E2E encrypted DM routes ──
|
||||
|
||||
# POST /keys — register public key: body = name=...&key=<JWK JSON>
|
||||
if method == "POST" and path == "/keys":
|
||||
params = urllib.parse.parse_qs(body.decode())
|
||||
name = params.get("name", [""])[0]
|
||||
key = params.get("key", [""])[0]
|
||||
if name and key:
|
||||
user_keys[name] = key
|
||||
save_keys()
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /keys/<username> — get public key
|
||||
if method == "GET" and path.startswith("/keys/"):
|
||||
username = urllib.parse.unquote(path[6:])
|
||||
key = user_keys.get(username)
|
||||
if key:
|
||||
resp = key.encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: application/json\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
else:
|
||||
writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /keys — list all registered usernames
|
||||
if method == "GET" and path == "/keys":
|
||||
resp = json.dumps(list(user_keys.keys())).encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\n")
|
||||
writer.write(b"Content-Type: application/json\r\n")
|
||||
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
|
||||
writer.write(b"\r\n")
|
||||
writer.write(resp)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# POST /dm — relay an encrypted DM: body = from=...&to=...&encrypted=...&nonce=...
|
||||
if method == "POST" and path == "/dm":
|
||||
params = urllib.parse.parse_qs(body.decode())
|
||||
sender = params.get("from", [""])[0]
|
||||
recipient = params.get("to", [""])[0]
|
||||
encrypted = params.get("encrypted", [""])[0]
|
||||
nonce = params.get("nonce", [""])[0]
|
||||
if sender and recipient and encrypted:
|
||||
dm_msg = {
|
||||
"ts": time.time(), "user": sender, "dm": True,
|
||||
"to": recipient, "encrypted": encrypted, "nonce": nonce
|
||||
}
|
||||
# Deliver to all SSE queues registered for this recipient
|
||||
for q in dm_targets.get(recipient, []):
|
||||
try:
|
||||
q.put_nowait(dm_msg)
|
||||
except Exception:
|
||||
pass
|
||||
# Also deliver to sender so they see their own DM
|
||||
if sender != recipient:
|
||||
for q in dm_targets.get(sender, []):
|
||||
try:
|
||||
q.put_nowait(dm_msg)
|
||||
except Exception:
|
||||
pass
|
||||
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
return
|
||||
|
||||
# GET /files/<id>/<filename> — download uploaded file
|
||||
if method == "GET" and path.startswith("/files/"):
|
||||
parts = path.split("/")
|
||||
@@ -640,16 +1209,17 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
await handle_http(reader, writer, first_line)
|
||||
return
|
||||
|
||||
# Raw TCP chat client — first line is the name
|
||||
# Raw TCP chat client — first line is the name (uses lobby group)
|
||||
name = first_line
|
||||
clients[writer] = name
|
||||
grp = get_group(DEFAULT_GROUP)
|
||||
grp.clients[writer] = name
|
||||
|
||||
for msg in history:
|
||||
for msg in grp.history:
|
||||
writer.write((json.dumps(msg) + "\n").encode())
|
||||
await writer.drain()
|
||||
|
||||
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"})
|
||||
print(f"+ {name} connected ({len(clients)} online)")
|
||||
await grp.broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"})
|
||||
print(f"+ {name} connected ({len(grp.clients)} online in {grp.name})")
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -659,7 +1229,6 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
line = data.decode().rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
# Try JSON (new protocol: supports multiline + files)
|
||||
try:
|
||||
pkt = json.loads(line)
|
||||
if pkt.get("type") == "file":
|
||||
@@ -668,7 +1237,7 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
continue
|
||||
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
|
||||
store_file(file_id, file_data)
|
||||
await broadcast({
|
||||
await grp.broadcast({
|
||||
"ts": time.time(), "user": name,
|
||||
"text": f"[file: {pkt['filename']}]",
|
||||
"file_id": file_id, "filename": pkt["filename"],
|
||||
@@ -677,17 +1246,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
else:
|
||||
text = pkt.get("text", "").strip()
|
||||
if text:
|
||||
await broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
await grp.broadcast({"ts": time.time(), "user": name, "text": text})
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# Legacy plain text (single line, backwards compat)
|
||||
if line.strip():
|
||||
await broadcast({"ts": time.time(), "user": name, "text": line.strip()})
|
||||
await grp.broadcast({"ts": time.time(), "user": name, "text": line.strip()})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
clients.pop(writer, None)
|
||||
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"})
|
||||
print(f"- {name} disconnected ({len(clients)} online)")
|
||||
grp.clients.pop(writer, None)
|
||||
await grp.broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"})
|
||||
print(f"- {name} disconnected ({len(grp.clients)} online in {grp.name})")
|
||||
writer.close()
|
||||
|
||||
|
||||
|
||||
1
warzone-phone
Submodule
1
warzone-phone
Submodule
Submodule warzone-phone added at 1d33f3ed4e
2
warzone/.gitignore
vendored
Normal file
2
warzone/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
warzone-data/
|
||||
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` |
|
||||
3631
warzone/Cargo.lock
generated
Normal file
3631
warzone/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
85
warzone/Cargo.toml
Normal file
85
warzone/Cargo.toml
Normal file
@@ -0,0 +1,85 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/warzone-protocol",
|
||||
"crates/warzone-server",
|
||||
"crates/warzone-client",
|
||||
"crates/warzone-mule",
|
||||
"crates/warzone-wasm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.47"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Crypto
|
||||
ed25519-dalek = { version = "2", features = ["serde", "rand_core"] }
|
||||
x25519-dalek = { version = "2", features = ["serde", "static_secrets"] }
|
||||
curve25519-dalek = "4"
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
|
||||
# Ethereum compatibility
|
||||
k256 = { version = "0.13", features = ["ecdsa", "serde"] }
|
||||
tiny-keccak = { version = "2", features = ["keccak"] }
|
||||
|
||||
# BIP39
|
||||
bip39 = "2"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bincode = "1"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Server
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
tower = { version = "0.4", features = ["limit"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
|
||||
# Client HTTP
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
# Database
|
||||
sled = "0.34"
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# TUI
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.28"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Hex encoding
|
||||
hex = "0.4"
|
||||
|
||||
# Base64
|
||||
base64 = "0.22"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# WebSocket client
|
||||
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
|
||||
|
||||
# Zero secrets in memory
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
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"}
|
||||
]
|
||||
33
warzone/crates/warzone-client/Cargo.toml
Normal file
33
warzone/crates/warzone-client/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "warzone-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
warzone-protocol = { path = "../warzone-protocol" }
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
sled.workspace = true
|
||||
clap.workspace = true
|
||||
ratatui.workspace = true
|
||||
crossterm.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
chacha20poly1305.workspace = true
|
||||
rand.workspace = true
|
||||
zeroize.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
x25519-dalek.workspace = true
|
||||
bincode.workspace = true
|
||||
sha2.workspace = true
|
||||
libc = "0.2"
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
1
warzone/crates/warzone-client/src/cli/info.rs
Normal file
1
warzone/crates/warzone-client/src/cli/info.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Info is now handled directly in main.rs with the pre-unlocked identity.
|
||||
101
warzone/crates/warzone-client/src/cli/init.rs
Normal file
101
warzone/crates/warzone-client/src/cli/init.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use anyhow::Result;
|
||||
use warzone_protocol::identity::Seed;
|
||||
use warzone_protocol::prekey::{
|
||||
generate_one_time_pre_keys, generate_signed_pre_key, OneTimePreKeyPublic, PreKeyBundle,
|
||||
};
|
||||
|
||||
use crate::keystore;
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let seed = Seed::generate();
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
let mnemonic = seed.to_mnemonic();
|
||||
|
||||
println!("Identity generated!\n");
|
||||
println!("Fingerprint: {}", pub_id.fingerprint);
|
||||
println!("\nRecovery mnemonic (WRITE THIS DOWN):\n");
|
||||
for (i, word) in mnemonic.split_whitespace().enumerate() {
|
||||
print!("{:>2}. {:<12}", i + 1, word);
|
||||
if (i + 1) % 4 == 0 {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
// Save encrypted seed
|
||||
keystore::save_seed(&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(())
|
||||
}
|
||||
5
warzone/crates/warzone-client/src/cli/mod.rs
Normal file
5
warzone/crates/warzone-client/src/cli/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod info;
|
||||
pub mod init;
|
||||
pub mod recover;
|
||||
pub mod send;
|
||||
pub mod recv;
|
||||
17
warzone/crates/warzone-client/src/cli/recover.rs
Normal file
17
warzone/crates/warzone-client/src/cli/recover.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use warzone_protocol::identity::Seed;
|
||||
|
||||
use crate::keystore;
|
||||
|
||||
pub fn run(mnemonic: &str) -> anyhow::Result<()> {
|
||||
let seed = Seed::from_mnemonic(mnemonic)?;
|
||||
let identity = seed.derive_identity();
|
||||
let pub_id = identity.public_identity();
|
||||
|
||||
println!("Identity recovered!");
|
||||
println!("Fingerprint: {}", pub_id.fingerprint);
|
||||
|
||||
keystore::save_seed(&seed)?;
|
||||
println!("Seed saved to ~/.warzone/identity.seed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
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(())
|
||||
}
|
||||
177
warzone/crates/warzone-client/src/keystore.rs
Normal file
177
warzone/crates/warzone-client/src/keystore.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
//! Seed storage: encrypted at rest with Argon2id + ChaCha20-Poly1305.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use argon2::Argon2;
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
ChaCha20Poly1305, Nonce,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use warzone_protocol::identity::Seed;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Magic bytes to identify encrypted seed files.
|
||||
const MAGIC: &[u8; 4] = b"WZS1";
|
||||
/// Salt length for Argon2.
|
||||
const SALT_LEN: usize = 16;
|
||||
/// Nonce length for ChaCha20-Poly1305.
|
||||
const NONCE_LEN: usize = 12;
|
||||
|
||||
/// Get the warzone data directory. Respects WARZONE_HOME env var,
|
||||
/// falls back to ~/.warzone.
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if let Ok(wz) = std::env::var("WARZONE_HOME") {
|
||||
PathBuf::from(wz)
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".warzone")
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_path() -> PathBuf {
|
||||
data_dir().join("identity.seed")
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key from a passphrase using Argon2id.
|
||||
fn derive_key(passphrase: &[u8], salt: &[u8]) -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(passphrase, salt, &mut key)
|
||||
.expect("Argon2 should not fail with valid params");
|
||||
key
|
||||
}
|
||||
|
||||
/// Prompt for a passphrase (hidden input).
|
||||
fn prompt_passphrase(prompt: &str) -> String {
|
||||
eprint!("{}", prompt);
|
||||
io::stderr().flush().unwrap();
|
||||
let mut pass = String::new();
|
||||
// Try to disable echo. If that fails (e.g. piped input), just read normally.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = io::stdin().as_raw_fd();
|
||||
let mut termios = unsafe {
|
||||
let mut t = std::mem::zeroed();
|
||||
libc::tcgetattr(fd, &mut t);
|
||||
t
|
||||
};
|
||||
let old = termios;
|
||||
termios.c_lflag &= !libc::ECHO;
|
||||
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) };
|
||||
io::stdin().read_line(&mut pass).unwrap();
|
||||
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) };
|
||||
eprintln!();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
io::stdin().read_line(&mut pass).unwrap();
|
||||
}
|
||||
pass.trim().to_string()
|
||||
}
|
||||
|
||||
/// Save seed encrypted with a passphrase.
|
||||
pub fn save_seed(seed: &Seed) -> anyhow::Result<()> {
|
||||
let path = seed_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let passphrase = prompt_passphrase("Set passphrase (empty for no encryption): ");
|
||||
|
||||
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)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load raw seed bytes (for deriving eth address etc).
|
||||
pub fn load_seed_raw() -> anyhow::Result<[u8; 32]> {
|
||||
let seed = load_seed()?;
|
||||
Ok(seed.0)
|
||||
}
|
||||
|
||||
/// Load seed, decrypting if necessary.
|
||||
pub fn load_seed() -> anyhow::Result<Seed> {
|
||||
let path = seed_path();
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|_| anyhow::anyhow!("No identity found. Run `warzone init` first."))?;
|
||||
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
5
warzone/crates/warzone-client/src/lib.rs
Normal file
5
warzone/crates/warzone-client/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod cli;
|
||||
pub mod keystore;
|
||||
pub mod net;
|
||||
pub mod storage;
|
||||
pub mod tui;
|
||||
146
warzone/crates/warzone-client/src/main.rs
Normal file
146
warzone/crates/warzone-client/src/main.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod cli;
|
||||
mod keystore;
|
||||
mod net;
|
||||
mod storage;
|
||||
mod tui;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "warzone", about = "Warzone messenger client")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Generate a new identity (seed + keypair + pre-keys)
|
||||
Init,
|
||||
/// Recover identity from BIP39 mnemonic
|
||||
Recover {
|
||||
/// 24-word mnemonic
|
||||
#[arg(num_args = 1..)]
|
||||
words: Vec<String>,
|
||||
},
|
||||
/// Show your fingerprint and public key
|
||||
Info,
|
||||
/// Register your key bundle with a server
|
||||
Register {
|
||||
/// Server URL
|
||||
#[arg(short, long, default_value = "http://localhost:7700")]
|
||||
server: String,
|
||||
},
|
||||
/// Show Ethereum-compatible address derived from your seed
|
||||
Eth,
|
||||
/// Send an encrypted message
|
||||
Send {
|
||||
/// Recipient fingerprint (e.g. a3f8:c912:...) or @alias
|
||||
recipient: String,
|
||||
/// Message text
|
||||
message: String,
|
||||
/// Server URL
|
||||
#[arg(short, long, default_value = "http://localhost:7700")]
|
||||
server: String,
|
||||
},
|
||||
/// Poll for and decrypt messages
|
||||
Recv {
|
||||
/// Server URL
|
||||
#[arg(short, long, default_value = "http://localhost:7700")]
|
||||
server: String,
|
||||
},
|
||||
/// Launch interactive TUI chat
|
||||
Chat {
|
||||
/// Peer fingerprint or @alias (optional)
|
||||
peer: Option<String>,
|
||||
/// Server URL
|
||||
#[arg(short, long, default_value = "http://localhost:7700")]
|
||||
server: String,
|
||||
},
|
||||
/// Export encrypted backup of local data (sessions, history)
|
||||
Backup {
|
||||
/// Output file path
|
||||
#[arg(default_value = "warzone-backup.wzb")]
|
||||
output: String,
|
||||
},
|
||||
/// Restore from encrypted backup
|
||||
Restore {
|
||||
/// Backup file path
|
||||
input: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
// These don't need an existing identity
|
||||
Commands::Init => return cli::init::run(),
|
||||
Commands::Recover { words } => return cli::recover::run(&words.join(" ")),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// All other commands need the seed — unlock once here
|
||||
let seed = keystore::load_seed()?;
|
||||
// Create a copy for the poll thread (Seed doesn't impl Clone due to Zeroize)
|
||||
let poll_seed = warzone_protocol::identity::Seed::from_bytes(seed.0);
|
||||
let identity = seed.derive_identity();
|
||||
let our_fp = identity.public_identity().fingerprint.to_string();
|
||||
|
||||
match cli.command {
|
||||
Commands::Init | Commands::Recover { .. } => unreachable!(),
|
||||
Commands::Info => {
|
||||
let pub_id = identity.public_identity();
|
||||
println!("Fingerprint: {}", pub_id.fingerprint);
|
||||
println!("Signing key: {}", hex::encode(pub_id.signing.as_bytes()));
|
||||
println!("Encryption key: {}", hex::encode(pub_id.encryption.as_bytes()));
|
||||
}
|
||||
Commands::Eth => {
|
||||
let eth_id = warzone_protocol::ethereum::derive_eth_identity(&seed.0);
|
||||
let pub_id = identity.public_identity();
|
||||
println!("Warzone fingerprint: {}", pub_id.fingerprint);
|
||||
println!("Ethereum address: {}", eth_id.address.to_checksum());
|
||||
println!("Same seed, dual identity.");
|
||||
}
|
||||
Commands::Register { server } => {
|
||||
cli::init::register_with_server_identity(&server, &identity).await?;
|
||||
}
|
||||
Commands::Send {
|
||||
recipient,
|
||||
message,
|
||||
server,
|
||||
} => {
|
||||
let _ = cli::init::register_with_server_identity(&server, &identity).await;
|
||||
cli::send::run(&recipient, &message, &server, &identity).await?;
|
||||
}
|
||||
Commands::Recv { server } => {
|
||||
cli::recv::run(&server, &identity).await?;
|
||||
}
|
||||
Commands::Chat { peer, server } => {
|
||||
let _ = cli::init::register_with_server_identity(&server, &identity).await;
|
||||
let db = storage::LocalDb::open()?;
|
||||
tui::run_tui(our_fp, peer, server, identity, poll_seed, db).await?;
|
||||
}
|
||||
Commands::Backup { output } => {
|
||||
// Collect all sled data as JSON
|
||||
let db = storage::LocalDb::open()?;
|
||||
let backup_data = db.export_all()?;
|
||||
let json = serde_json::to_vec(&backup_data)?;
|
||||
let encrypted = warzone_protocol::history::encrypt_history(&seed.0, &json);
|
||||
std::fs::write(&output, &encrypted)?;
|
||||
println!("Backup saved to {} ({} bytes encrypted)", output, encrypted.len());
|
||||
}
|
||||
Commands::Restore { input } => {
|
||||
let encrypted = std::fs::read(&input)?;
|
||||
let json = warzone_protocol::history::decrypt_history(&seed.0, &encrypted)
|
||||
.map_err(|_| anyhow::anyhow!("Decryption failed — wrong seed?"))?;
|
||||
let backup_data: serde_json::Value = serde_json::from_slice(&json)?;
|
||||
let db = storage::LocalDb::open()?;
|
||||
let count = db.import_all(&backup_data)?;
|
||||
println!("Restored {} entries from {}", count, input);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
172
warzone/crates/warzone-client/src/net.rs
Normal file
172
warzone/crates/warzone-client/src/net.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! HTTP client for talking to warzone-server.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warzone_protocol::prekey::PreKeyBundle;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerClient {
|
||||
pub base_url: String,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RegisterRequest {
|
||||
fingerprint: String,
|
||||
bundle: Vec<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
eth_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SendRequest {
|
||||
to: String,
|
||||
from: Option<String>,
|
||||
message: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct BundleResponse {
|
||||
fingerprint: String,
|
||||
bundle: String, // base64
|
||||
}
|
||||
|
||||
impl ServerClient {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
ServerClient {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register our pre-key bundle with the server.
|
||||
pub async fn register_bundle(
|
||||
&self,
|
||||
fingerprint: &str,
|
||||
bundle: &PreKeyBundle,
|
||||
eth_address: Option<String>,
|
||||
) -> Result<()> {
|
||||
let encoded =
|
||||
bincode::serialize(bundle).context("failed to serialize bundle")?;
|
||||
self.client
|
||||
.post(format!("{}/v1/keys/register", self.base_url))
|
||||
.json(&RegisterRequest {
|
||||
fingerprint: fingerprint.to_string(),
|
||||
bundle: encoded,
|
||||
eth_address,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.context("failed to register bundle")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a user's pre-key bundle from the server.
|
||||
pub async fn fetch_bundle(&self, fingerprint: &str) -> Result<PreKeyBundle> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let response = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/v1/keys/{}",
|
||||
self.base_url, fp_clean
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to fetch bundle")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"server returned {} — user {} may not be registered",
|
||||
response.status(),
|
||||
fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
let resp: BundleResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse bundle response")?;
|
||||
|
||||
let bytes = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&resp.bundle,
|
||||
)
|
||||
.context("failed to decode base64 bundle")?;
|
||||
|
||||
bincode::deserialize(&bytes).context("failed to deserialize bundle")
|
||||
}
|
||||
|
||||
/// Send an encrypted message to the server for delivery.
|
||||
pub async fn send_message(&self, to: &str, from: Option<&str>, message: &[u8]) -> Result<()> {
|
||||
let to_clean: String = to.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
self.client
|
||||
.post(format!("{}/v1/messages/send", self.base_url))
|
||||
.json(&SendRequest {
|
||||
to: to_clean,
|
||||
from: from.map(|f| f.chars().filter(|c| c.is_ascii_hexdigit()).collect()),
|
||||
message: message.to_vec(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send message")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check how many one-time pre-keys remain on the server.
|
||||
pub async fn otpk_count(&self, fingerprint: &str) -> Result<u64> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let resp: serde_json::Value = self.client
|
||||
.get(format!("{}/v1/keys/{}/otpk-count", self.base_url, fp_clean))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to check OTPK count")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse OTPK count")?;
|
||||
Ok(resp.get("count").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Upload additional one-time pre-keys.
|
||||
pub async fn replenish_otpks(&self, fingerprint: &str, keys: Vec<(u32, [u8; 32])>) -> Result<()> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let otpks: Vec<serde_json::Value> = keys.iter().map(|(id, pubkey)| {
|
||||
serde_json::json!({"id": id, "public_key": hex::encode(pubkey)})
|
||||
}).collect();
|
||||
self.client
|
||||
.post(format!("{}/v1/keys/replenish", self.base_url))
|
||||
.json(&serde_json::json!({"fingerprint": fp_clean, "one_time_pre_keys": otpks}))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to replenish OTPKs")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll for messages addressed to us.
|
||||
pub async fn poll_messages(&self, fingerprint: &str) -> Result<Vec<Vec<u8>>> {
|
||||
let fp_clean: String = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let resp: Vec<String> = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/v1/messages/poll/{}",
|
||||
self.base_url, fp_clean
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to poll messages")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse poll response")?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for b64 in resp {
|
||||
if let Ok(bytes) = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&b64,
|
||||
) {
|
||||
messages.push(bytes);
|
||||
}
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
412
warzone/crates/warzone-client/src/storage.rs
Normal file
412
warzone/crates/warzone-client/src/storage.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
//! Local sled database: sessions, pre-keys, message history.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
use x25519_dalek::StaticSecret;
|
||||
|
||||
pub struct LocalDb {
|
||||
sessions: sled::Tree,
|
||||
pre_keys: sled::Tree,
|
||||
contacts: sled::Tree,
|
||||
history: sled::Tree,
|
||||
sender_keys: sled::Tree,
|
||||
_db: sled::Db,
|
||||
}
|
||||
|
||||
impl LocalDb {
|
||||
pub fn open() -> Result<Self> {
|
||||
let path = crate::keystore::data_dir().join("db");
|
||||
let db = match sled::open(&path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("WouldBlock") || err_str.contains("lock") {
|
||||
eprintln!("Error: Database is locked by another warzone process.");
|
||||
eprintln!(" DB path: {}", path.display());
|
||||
eprintln!();
|
||||
eprintln!(" Check for running processes:");
|
||||
eprintln!(" ps aux | grep warzone-client");
|
||||
eprintln!();
|
||||
eprintln!(" To force unlock (if no other process is running):");
|
||||
eprintln!(" rm -rf {}", path.display());
|
||||
eprintln!(" (This deletes sessions — you'll need to re-establish them)");
|
||||
anyhow::bail!("database locked by another process");
|
||||
}
|
||||
return Err(e).context("failed to open local database");
|
||||
}
|
||||
};
|
||||
let sessions = db.open_tree("sessions")?;
|
||||
let pre_keys = db.open_tree("pre_keys")?;
|
||||
let contacts = db.open_tree("contacts")?;
|
||||
let history = db.open_tree("history")?;
|
||||
let sender_keys = db.open_tree("sender_keys")?;
|
||||
Ok(LocalDb {
|
||||
sessions,
|
||||
pre_keys,
|
||||
contacts,
|
||||
history,
|
||||
sender_keys,
|
||||
_db: db,
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a ratchet session for a peer.
|
||||
pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> {
|
||||
let key = peer.to_hex();
|
||||
let data = state.serialize_versioned()
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
self.sessions.insert(key.as_bytes(), data)?;
|
||||
self.sessions.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a ratchet session for a peer (used for session recovery).
|
||||
pub fn delete_session(&self, peer: &Fingerprint) -> Result<()> {
|
||||
let key = peer.to_hex();
|
||||
self.sessions.remove(key.as_bytes())?;
|
||||
self.sessions.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a ratchet session for a peer.
|
||||
pub fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>> {
|
||||
let key = peer.to_hex();
|
||||
match self.sessions.get(key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let state = RatchetState::deserialize_versioned(&data)
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the signed pre-key secret (for X3DH respond).
|
||||
pub fn save_signed_pre_key(&self, id: u32, secret: &StaticSecret) -> Result<()> {
|
||||
let key = format!("spk:{}", id);
|
||||
self.pre_keys
|
||||
.insert(key.as_bytes(), secret.to_bytes().as_slice())?;
|
||||
self.pre_keys.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the signed pre-key secret.
|
||||
pub fn load_signed_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
|
||||
let key = format!("spk:{}", id);
|
||||
match self.pre_keys.get(key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&data);
|
||||
Ok(Some(StaticSecret::from(bytes)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a one-time pre-key secret.
|
||||
pub fn save_one_time_pre_key(&self, id: u32, secret: &StaticSecret) -> Result<()> {
|
||||
let key = format!("otpk:{}", id);
|
||||
self.pre_keys
|
||||
.insert(key.as_bytes(), secret.to_bytes().as_slice())?;
|
||||
self.pre_keys.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the next available OTPK ID (one past the highest stored).
|
||||
pub fn next_otpk_id(&self) -> u32 {
|
||||
let mut max_id: Option<u32> = None;
|
||||
for item in self.pre_keys.iter() {
|
||||
if let Ok((k, _)) = item {
|
||||
let key_str = String::from_utf8_lossy(&k);
|
||||
if let Some(id_str) = key_str.strip_prefix("otpk:") {
|
||||
if let Ok(id) = id_str.parse::<u32>() {
|
||||
max_id = Some(max_id.map_or(id, |m: u32| m.max(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
max_id.map_or(0, |m| m + 1)
|
||||
}
|
||||
|
||||
/// Load and remove a one-time pre-key secret.
|
||||
pub fn take_one_time_pre_key(&self, id: u32) -> Result<Option<StaticSecret>> {
|
||||
let key = format!("otpk:{}", id);
|
||||
match self.pre_keys.remove(key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&data);
|
||||
self.pre_keys.flush()?;
|
||||
Ok(Some(StaticSecret::from(bytes)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sender Keys ──
|
||||
|
||||
/// Save a sender key for a (sender, group) pair.
|
||||
pub fn save_sender_key(
|
||||
&self,
|
||||
sender_fp: &str,
|
||||
group_name: &str,
|
||||
key: &warzone_protocol::sender_keys::SenderKey,
|
||||
) -> Result<()> {
|
||||
let db_key = format!("sk:{}:{}", sender_fp, group_name);
|
||||
let data = bincode::serialize(key).context("failed to serialize sender key")?;
|
||||
self.sender_keys.insert(db_key.as_bytes(), data)?;
|
||||
self.sender_keys.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a sender key for a (sender, group) pair.
|
||||
pub fn load_sender_key(
|
||||
&self,
|
||||
sender_fp: &str,
|
||||
group_name: &str,
|
||||
) -> Result<Option<warzone_protocol::sender_keys::SenderKey>> {
|
||||
let db_key = format!("sk:{}:{}", sender_fp, group_name);
|
||||
match self.sender_keys.get(db_key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let key = bincode::deserialize(&data)
|
||||
.context("failed to deserialize sender key")?;
|
||||
Ok(Some(key))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contacts ──
|
||||
|
||||
/// Add or update a contact. Called on send/receive.
|
||||
pub fn touch_contact(&self, fingerprint: &str, alias: Option<&str>) -> Result<()> {
|
||||
let fp = fingerprint.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
let mut record = match self.contacts.get(fp.as_bytes())? {
|
||||
Some(data) => serde_json::from_slice::<serde_json::Value>(&data).unwrap_or_default(),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
let obj = record.as_object_mut().unwrap();
|
||||
obj.insert("fingerprint".into(), serde_json::json!(fp));
|
||||
obj.insert("last_seen".into(), serde_json::json!(now));
|
||||
if let Some(a) = alias {
|
||||
obj.insert("alias".into(), serde_json::json!(a));
|
||||
}
|
||||
if !obj.contains_key("first_seen") {
|
||||
obj.insert("first_seen".into(), serde_json::json!(now));
|
||||
}
|
||||
let count = obj.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
obj.insert("message_count".into(), serde_json::json!(count + 1));
|
||||
|
||||
self.contacts.insert(fp.as_bytes(), serde_json::to_vec(&record)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all contacts sorted by last_seen (most recent first).
|
||||
pub fn list_contacts(&self) -> Result<Vec<serde_json::Value>> {
|
||||
let mut contacts: Vec<serde_json::Value> = self.contacts.iter()
|
||||
.filter_map(|item| {
|
||||
item.ok().and_then(|(_, data)| serde_json::from_slice(&data).ok())
|
||||
})
|
||||
.collect();
|
||||
contacts.sort_by(|a, b| {
|
||||
let ta = a.get("last_seen").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("last_seen").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
// ── Message History ──
|
||||
|
||||
/// Store a message in local history.
|
||||
pub fn store_message(&self, peer_fp: &str, sender: &str, text: &str, is_self: bool) -> Result<()> {
|
||||
let fp = peer_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let msg = serde_json::json!({
|
||||
"id": id,
|
||||
"peer": fp,
|
||||
"sender": sender,
|
||||
"text": text,
|
||||
"is_self": is_self,
|
||||
"timestamp": now,
|
||||
});
|
||||
|
||||
// Key: hist:<peer_fp>:<timestamp>:<uuid> for ordered scan
|
||||
let key = format!("hist:{}:{}:{}", fp, now, id);
|
||||
self.history.insert(key.as_bytes(), serde_json::to_vec(&msg)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get message history with a peer (most recent N messages).
|
||||
pub fn get_history(&self, peer_fp: &str, limit: usize) -> Result<Vec<serde_json::Value>> {
|
||||
let fp = peer_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
let prefix = format!("hist:{}:", fp);
|
||||
|
||||
let mut messages: Vec<serde_json::Value> = self.history
|
||||
.scan_prefix(prefix.as_bytes())
|
||||
.filter_map(|item| {
|
||||
item.ok().and_then(|(_, data)| serde_json::from_slice(&data).ok())
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Take last N
|
||||
if messages.len() > limit {
|
||||
messages = messages.split_off(messages.len() - limit);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Export all data as JSON (for encrypted backup).
|
||||
pub fn export_all(&self) -> Result<serde_json::Value> {
|
||||
let mut sessions = serde_json::Map::new();
|
||||
for item in self.sessions.iter() {
|
||||
if let Ok((k, v)) = item {
|
||||
let key = String::from_utf8_lossy(&k).to_string();
|
||||
sessions.insert(key, serde_json::json!(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD, &v
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut pre_keys = serde_json::Map::new();
|
||||
for item in self.pre_keys.iter() {
|
||||
if let Ok((k, v)) = item {
|
||||
let key = String::from_utf8_lossy(&k).to_string();
|
||||
pre_keys.insert(key, serde_json::json!(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD, &v
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"version": 1,
|
||||
"sessions": sessions,
|
||||
"pre_keys": pre_keys,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create an encrypted backup of all session data.
|
||||
/// Returns the backup file path.
|
||||
pub fn create_backup(&self, seed: &[u8; 32]) -> Result<std::path::PathBuf> {
|
||||
use std::io::Write;
|
||||
|
||||
let backup_dir = crate::keystore::data_dir().join("backups");
|
||||
std::fs::create_dir_all(&backup_dir)?;
|
||||
|
||||
// Collect all data
|
||||
let mut data = serde_json::Map::new();
|
||||
|
||||
// Sessions
|
||||
let mut sessions = serde_json::Map::new();
|
||||
for item in self.sessions.iter() {
|
||||
if let Ok((key, value)) = item {
|
||||
let k = String::from_utf8_lossy(&key).to_string();
|
||||
sessions.insert(k, serde_json::Value::String(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD, &value
|
||||
)));
|
||||
}
|
||||
}
|
||||
data.insert("sessions".into(), serde_json::Value::Object(sessions));
|
||||
|
||||
// Contacts
|
||||
let mut contacts = serde_json::Map::new();
|
||||
for item in self.contacts.iter() {
|
||||
if let Ok((key, value)) = item {
|
||||
let k = String::from_utf8_lossy(&key).to_string();
|
||||
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||
contacts.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
data.insert("contacts".into(), serde_json::Value::Object(contacts));
|
||||
|
||||
// Sender keys
|
||||
let mut sender_keys = serde_json::Map::new();
|
||||
for item in self.sender_keys.iter() {
|
||||
if let Ok((key, value)) = item {
|
||||
let k = String::from_utf8_lossy(&key).to_string();
|
||||
sender_keys.insert(k, serde_json::Value::String(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD, &value
|
||||
)));
|
||||
}
|
||||
}
|
||||
data.insert("sender_keys".into(), serde_json::Value::Object(sender_keys));
|
||||
|
||||
// Serialize and encrypt
|
||||
let plaintext = serde_json::to_vec(&serde_json::Value::Object(data))?;
|
||||
let key_bytes = warzone_protocol::crypto::hkdf_derive(seed, b"", b"warzone-backup", 32);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
let encrypted = warzone_protocol::crypto::aead_encrypt(&key, &plaintext, b"warzone-backup-aad");
|
||||
|
||||
// Write to temp file then rename (atomic)
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let filename = format!("backup_{}.wzbk", timestamp);
|
||||
let path = backup_dir.join(&filename);
|
||||
let tmp_path = backup_dir.join(format!(".{}.tmp", filename));
|
||||
|
||||
let mut file = std::fs::File::create(&tmp_path)?;
|
||||
file.write_all(&encrypted)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp_path, &path)?;
|
||||
|
||||
// Rotate: keep last 3 backups
|
||||
let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".wzbk"))
|
||||
.collect();
|
||||
backups.sort_by_key(|e| e.file_name());
|
||||
while backups.len() > 3 {
|
||||
if let Some(old) = backups.first() {
|
||||
let _ = std::fs::remove_file(old.path());
|
||||
backups.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Import data from JSON backup (merges, doesn't overwrite existing).
|
||||
pub fn import_all(&self, data: &serde_json::Value) -> Result<usize> {
|
||||
let mut count = 0;
|
||||
|
||||
if let Some(sessions) = data.get("sessions").and_then(|v| v.as_object()) {
|
||||
for (key, val) in sessions {
|
||||
if let Some(b64) = val.as_str() {
|
||||
if let Ok(bytes) = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD, b64
|
||||
) {
|
||||
// Only import if not already present
|
||||
if self.sessions.get(key.as_bytes())?.is_none() {
|
||||
self.sessions.insert(key.as_bytes(), bytes)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pre_keys) = data.get("pre_keys").and_then(|v| v.as_object()) {
|
||||
for (key, val) in pre_keys {
|
||||
if let Some(b64) = val.as_str() {
|
||||
if let Ok(bytes) = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD, b64
|
||||
) {
|
||||
if self.pre_keys.get(key.as_bytes())?.is_none() {
|
||||
self.pre_keys.insert(key.as_bytes(), bytes)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.sessions.flush()?;
|
||||
self.pre_keys.flush()?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
195
warzone/crates/warzone-client/src/tui/mod.rs
Normal file
195
warzone/crates/warzone-client/src/tui/mod.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
mod types;
|
||||
mod draw;
|
||||
mod commands;
|
||||
mod file_transfer;
|
||||
mod input;
|
||||
mod network;
|
||||
|
||||
pub use types::App;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
|
||||
use warzone_protocol::identity::{IdentityKeyPair, Seed};
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
/// Run the TUI event loop.
|
||||
pub async fn run_tui(
|
||||
our_fp: String,
|
||||
peer_fp: Option<String>,
|
||||
server_url: String,
|
||||
identity: IdentityKeyPair,
|
||||
poll_seed: Seed,
|
||||
db: LocalDb,
|
||||
) -> Result<()> {
|
||||
let mut terminal = ratatui::init();
|
||||
let client = ServerClient::new(&server_url);
|
||||
let db = Arc::new(db);
|
||||
|
||||
let mut app = App::new(our_fp.clone(), peer_fp, server_url);
|
||||
|
||||
// Derive a second identity for the poll loop (can't clone IdentityKeyPair)
|
||||
let poll_identity = poll_seed.derive_identity();
|
||||
let poll_messages = app.messages.clone();
|
||||
let poll_receipts = app.receipts.clone();
|
||||
let poll_pending_files = app.pending_files.clone();
|
||||
let poll_last_dm = app.last_dm_peer.clone();
|
||||
let poll_connected = app.connected.clone();
|
||||
let poll_client = client.clone();
|
||||
let poll_db = db.clone();
|
||||
let poll_fp = our_fp.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await;
|
||||
});
|
||||
|
||||
// Spawn periodic backup task (every 5 minutes)
|
||||
{
|
||||
let backup_db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Ok(seed) = crate::keystore::load_seed_raw() {
|
||||
match backup_db.create_backup(&seed) {
|
||||
Ok(path) => tracing::debug!("Auto-backup created: {}", path.display()),
|
||||
Err(e) => tracing::warn!("Auto-backup failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-join #ops if no peer set (create if needed)
|
||||
if app.peer_fp.is_none() {
|
||||
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
// Create #ops if it doesn't exist
|
||||
let _ = client.client.post(format!("{}/v1/groups/create", client.base_url))
|
||||
.json(&serde_json::json!({"name": "ops", "creator": fp_clean}))
|
||||
.send().await;
|
||||
// Join
|
||||
let _ = client.client.post(format!("{}/v1/groups/ops/join", client.base_url))
|
||||
.json(&serde_json::json!({"fingerprint": fp_clean}))
|
||||
.send().await;
|
||||
app.peer_fp = Some("#ops".to_string());
|
||||
app.add_message(types::ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Welcome! You have been added to #ops".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: chrono::Local::now(),
|
||||
});
|
||||
|
||||
// Show system bots
|
||||
if let Ok(resp) = client.client.get(format!("{}/v1/bot/list", client.base_url)).send().await {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(bots) = data.get("bots").and_then(|v| v.as_array()) {
|
||||
if !bots.is_empty() {
|
||||
app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
|
||||
for b in bots {
|
||||
let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let desc = b.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
|
||||
}
|
||||
app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: chrono::Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check and replenish OTPKs if running low
|
||||
{
|
||||
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
match client.otpk_count(&fp_clean).await {
|
||||
Ok(count) => {
|
||||
if count < 3 {
|
||||
tracing::info!("OTPK supply low ({}), generating more...", count);
|
||||
let start_id = db.next_otpk_id();
|
||||
let otpks = warzone_protocol::prekey::generate_one_time_pre_keys(start_id, 10);
|
||||
let mut new_keys = Vec::new();
|
||||
for otpk in &otpks {
|
||||
let _ = db.save_one_time_pre_key(otpk.id, &otpk.secret);
|
||||
new_keys.push((otpk.id, *otpk.public.as_bytes()));
|
||||
}
|
||||
match client.replenish_otpks(&fp_clean, new_keys).await {
|
||||
Ok(_) => {
|
||||
app.add_message(types::ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Replenished OTPKs ({} -> {})", count, count + 10),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: chrono::Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => tracing::warn!("Failed to replenish OTPKs: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("Could not check OTPK count: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| app.draw(frame))?;
|
||||
|
||||
// Send Read receipts for visible messages
|
||||
{
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
let total = msgs.len();
|
||||
let visible_end = total.saturating_sub(app.scroll_offset);
|
||||
let visible_height = 20; // approximate
|
||||
let visible_start = visible_end.saturating_sub(visible_height);
|
||||
|
||||
let mut sent = app.read_receipts_sent.lock().unwrap();
|
||||
for msg in &msgs[visible_start..visible_end] {
|
||||
if msg.is_system || msg.is_self { continue; }
|
||||
if let (Some(ref msg_id), Some(ref sfp)) = (&msg.message_id, &msg.sender_fp) {
|
||||
if sent.contains(msg_id) { continue; }
|
||||
sent.insert(msg_id.clone());
|
||||
// Fire-and-forget Read receipt
|
||||
let receipt = warzone_protocol::message::WireMessage::Receipt {
|
||||
sender_fingerprint: app.our_fp.clone(),
|
||||
message_id: msg_id.clone(),
|
||||
receipt_type: warzone_protocol::message::ReceiptType::Read,
|
||||
};
|
||||
if let Ok(encoded) = bincode::serialize(&receipt) {
|
||||
let client = client.clone();
|
||||
let to = sfp.clone();
|
||||
let from = app.our_fp.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = client.send_message(&to, Some(&from), &encoded).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.code == KeyCode::Enter {
|
||||
app.handle_send(&identity, &db, &client).await;
|
||||
app.scroll_offset = 0;
|
||||
} else {
|
||||
app.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
707
warzone/crates/warzone-client/src/tui/network.rs
Normal file
707
warzone/crates/warzone-client/src/tui/network.rs
Normal file
@@ -0,0 +1,707 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
use warzone_protocol::identity::IdentityKeyPair;
|
||||
use warzone_protocol::message::{ReceiptType, WireMessage};
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
use warzone_protocol::x3dh;
|
||||
use x25519_dalek::PublicKey;
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
use chrono::Local;
|
||||
|
||||
use super::types::{ChatLine, PendingFileTransfer, ReceiptStatus, normfp};
|
||||
|
||||
/// Send a delivery receipt for a message back to its sender.
|
||||
fn send_receipt(
|
||||
our_fp: &str,
|
||||
sender_fp: &str,
|
||||
message_id: &str,
|
||||
receipt_type: ReceiptType,
|
||||
client: &ServerClient,
|
||||
) {
|
||||
let receipt = WireMessage::Receipt {
|
||||
sender_fingerprint: our_fp.to_string(),
|
||||
message_id: message_id.to_string(),
|
||||
receipt_type,
|
||||
};
|
||||
let encoded = match bincode::serialize(&receipt) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
let client = client.clone();
|
||||
let to = sender_fp.to_string();
|
||||
let from = our_fp.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = client.send_message(&to, Some(&from), &encoded).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// ETH address cache: fingerprint → ETH address (populated async, read sync).
|
||||
pub type EthCache = Arc<std::sync::Mutex<HashMap<String, String>>>;
|
||||
|
||||
/// Display a fingerprint as short ETH address if cached, otherwise truncated fingerprint.
|
||||
fn display_sender(fp: &str, eth_cache: &EthCache) -> String {
|
||||
let cache = eth_cache.lock().unwrap();
|
||||
if let Some(eth) = cache.get(fp) {
|
||||
format!("{}...", ð[..eth.len().min(12)])
|
||||
} else {
|
||||
fp[..fp.len().min(12)].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Async: look up ETH address for a fingerprint and cache it.
|
||||
fn cache_eth_lookup(fp: &str, client: &ServerClient, eth_cache: &EthCache) {
|
||||
let fp = fp.to_string();
|
||||
let client = client.clone();
|
||||
let cache = eth_cache.clone();
|
||||
// Check if already cached
|
||||
if cache.lock().unwrap().contains_key(&fp) { return; }
|
||||
tokio::spawn(async move {
|
||||
let url = format!("{}/v1/resolve/{}", client.base_url, fp);
|
||||
if let Ok(resp) = client.client.get(&url).send().await {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
|
||||
cache.lock().unwrap().insert(fp, eth.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Pre-populate the ETH cache for all known contacts.
|
||||
pub async fn prefill_eth_cache(
|
||||
db: &crate::storage::LocalDb,
|
||||
client: &ServerClient,
|
||||
eth_cache: &EthCache,
|
||||
) {
|
||||
if let Ok(contacts) = db.list_contacts() {
|
||||
for c in &contacts {
|
||||
if let Some(fp) = c.get("fingerprint").and_then(|v| v.as_str()) {
|
||||
let fp = fp.to_string();
|
||||
if eth_cache.lock().unwrap().contains_key(&fp) { continue; }
|
||||
let url = format!("{}/v1/resolve/{}", client.base_url, fp);
|
||||
if let Ok(resp) = client.client.get(&url).send().await {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
|
||||
eth_cache.lock().unwrap().insert(fp, eth.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
|
||||
let _ = db.touch_contact(sender_fp, None);
|
||||
let _ = db.store_message(sender_fp, sender_fp, text, false);
|
||||
}
|
||||
|
||||
/// Process a single incoming raw message (shared by WS and HTTP paths).
|
||||
pub fn process_incoming(
|
||||
raw: &[u8],
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: &str,
|
||||
client: &ServerClient,
|
||||
eth_cache: &EthCache,
|
||||
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
match warzone_protocol::message::deserialize_envelope(raw) {
|
||||
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer, eth_cache),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_wire_message(
|
||||
wire: WireMessage,
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: &str,
|
||||
client: &ServerClient,
|
||||
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||
eth_cache: &EthCache,
|
||||
) {
|
||||
match wire {
|
||||
WireMessage::KeyExchange {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
sender_identity_encryption_key,
|
||||
ephemeral_public,
|
||||
used_one_time_pre_key_id,
|
||||
ratchet_message,
|
||||
} => {
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let spk_secret = match db.load_signed_pre_key(1) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id {
|
||||
db.take_one_time_pre_key(otpk_id).ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let their_id_x25519 = PublicKey::from(sender_identity_encryption_key);
|
||||
let their_eph = PublicKey::from(ephemeral_public);
|
||||
let shared_secret = match x3dh::respond(
|
||||
identity, &spk_secret, otpk_secret.as_ref(), &their_id_x25519, &their_eph,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
|
||||
match state.decrypt(&ratchet_message) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
if normfp(&sender_fingerprint) != normfp(our_fp) {
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
}
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) },
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), timestamp: Local::now(),
|
||||
});
|
||||
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||
// Terminal bell for incoming DM
|
||||
print!("\x07");
|
||||
}
|
||||
Err(e) => {
|
||||
// Session auto-recovery: delete corrupted session, show warning
|
||||
let _ = db.delete_session(&sender_fp);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::Message {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
ratchet_message,
|
||||
} => {
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = match db.load_session(&sender_fp) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
match state.decrypt(&ratchet_message) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
if normfp(&sender_fingerprint) != normfp(our_fp) {
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
}
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) },
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), timestamp: Local::now(),
|
||||
});
|
||||
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||
// Terminal bell for incoming DM
|
||||
print!("\x07");
|
||||
}
|
||||
Err(e) => {
|
||||
// Session auto-recovery: delete corrupted session, show warning
|
||||
let _ = db.delete_session(&sender_fp);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::Receipt {
|
||||
sender_fingerprint: _,
|
||||
message_id,
|
||||
receipt_type,
|
||||
} => {
|
||||
// Update receipt status for the referenced message
|
||||
let mut r = receipts.lock().unwrap();
|
||||
let current = r.get(&message_id);
|
||||
let should_update = match (&receipt_type, current) {
|
||||
(ReceiptType::Read, _) => true,
|
||||
(ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true,
|
||||
(ReceiptType::Delivered, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if should_update {
|
||||
let new_status = match receipt_type {
|
||||
ReceiptType::Delivered => ReceiptStatus::Delivered,
|
||||
ReceiptType::Read => ReceiptStatus::Read,
|
||||
};
|
||||
r.insert(message_id, new_status);
|
||||
}
|
||||
}
|
||||
WireMessage::FileHeader {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
filename,
|
||||
file_size,
|
||||
total_chunks,
|
||||
sha256,
|
||||
} => {
|
||||
let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)];
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Incoming file '{}' from {} ({} bytes, {} chunks)",
|
||||
filename, short_sender, file_size, total_chunks
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
let transfer = PendingFileTransfer {
|
||||
filename,
|
||||
total_chunks,
|
||||
received: 0,
|
||||
chunks: vec![None; total_chunks as usize],
|
||||
sha256,
|
||||
file_size,
|
||||
};
|
||||
pending_files.lock().unwrap().insert(id, transfer);
|
||||
}
|
||||
WireMessage::FileChunk {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
filename: _,
|
||||
chunk_index,
|
||||
total_chunks: _,
|
||||
data,
|
||||
} => {
|
||||
// Decrypt the chunk data using our ratchet session with the sender
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = match db.load_session(&sender_fp) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// The data field is a bincode-serialized RatchetMessage
|
||||
let ratchet_msg = match bincode::deserialize(&data) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let plaintext = match state.decrypt(&ratchet_msg) {
|
||||
Ok(pt) => {
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
pt
|
||||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut pf = pending_files.lock().unwrap();
|
||||
if let Some(transfer) = pf.get_mut(&id) {
|
||||
if (chunk_index as usize) < transfer.chunks.len() {
|
||||
if transfer.chunks[chunk_index as usize].is_none() {
|
||||
transfer.chunks[chunk_index as usize] = Some(plaintext);
|
||||
transfer.received += 1;
|
||||
}
|
||||
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Receiving {} [{}/{}]...",
|
||||
transfer.filename, transfer.received, transfer.total_chunks
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
// Check if all chunks received
|
||||
if transfer.received == transfer.total_chunks {
|
||||
let mut assembled = Vec::with_capacity(transfer.file_size as usize);
|
||||
for chunk in &transfer.chunks {
|
||||
if let Some(data) = chunk {
|
||||
assembled.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify SHA-256
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&assembled);
|
||||
let computed_hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
if computed_hash != transfer.sha256 {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"File '{}' integrity check FAILED (hash mismatch)",
|
||||
transfer.filename
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
} else {
|
||||
// Save to data_dir/downloads/
|
||||
let download_dir = crate::keystore::data_dir().join("downloads");
|
||||
let _ = std::fs::create_dir_all(&download_dir);
|
||||
let save_path = download_dir.join(&transfer.filename);
|
||||
match std::fs::write(&save_path, &assembled) {
|
||||
Ok(_) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"File saved: {}",
|
||||
save_path.display()
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to save file: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove completed transfer
|
||||
pf.remove(&id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Received chunk without header — ignore
|
||||
}
|
||||
}
|
||||
WireMessage::GroupSenderKey {
|
||||
id: _,
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
} => {
|
||||
match db.load_sender_key(&sender_fingerprint, &group_name) {
|
||||
Ok(Some(mut sender_key)) => {
|
||||
let msg = warzone_protocol::sender_keys::SenderKeyMessage {
|
||||
sender_fingerprint: sender_fingerprint.clone(),
|
||||
group_name: group_name.clone(),
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
};
|
||||
match sender_key.decrypt(&msg) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
// Save updated sender key (counter advanced)
|
||||
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: format!(
|
||||
"{} [#{}]",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
group_name
|
||||
),
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[group #{}] decrypt failed from {}: {}",
|
||||
group_name,
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
e
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[group #{}] no sender key for {} — key distribution needed",
|
||||
group_name,
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::SenderKeyDistribution {
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
chain_key,
|
||||
generation,
|
||||
} => {
|
||||
let dist = warzone_protocol::sender_keys::SenderKeyDistribution {
|
||||
sender_fingerprint: sender_fingerprint.clone(),
|
||||
group_name: group_name.clone(),
|
||||
chain_key,
|
||||
generation,
|
||||
};
|
||||
let sender_key = dist.into_sender_key();
|
||||
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Received sender key from {} for #{}",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
group_name
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
WireMessage::CallSignal {
|
||||
id: _,
|
||||
sender_fingerprint,
|
||||
signal_type,
|
||||
payload: _,
|
||||
target: _,
|
||||
} => {
|
||||
use warzone_protocol::message::CallSignalType;
|
||||
let sender_short = { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) };
|
||||
match signal_type {
|
||||
CallSignalType::Offer => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("\u{1f4de} Incoming call from {} \u{2014} /accept or /reject", sender_short),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
// Terminal bell for incoming call
|
||||
print!("\x07");
|
||||
}
|
||||
CallSignalType::Answer => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("\u{2713} {} accepted the call", sender_short),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
CallSignalType::Hangup => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Call ended".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
CallSignalType::Reject => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("{} rejected the call", sender_short),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
CallSignalType::Ringing => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Ringing...".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
CallSignalType::Busy => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("{} is busy", sender_short),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: sender_short,
|
||||
text: format!("\u{1f4de} Call signal: {:?}", signal_type),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time message loop via WebSocket (falls back to HTTP polling).
|
||||
pub async fn poll_loop(
|
||||
messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: String,
|
||||
identity: IdentityKeyPair,
|
||||
db: Arc<LocalDb>,
|
||||
client: ServerClient,
|
||||
last_dm_peer: Arc<Mutex<Option<String>>>,
|
||||
connected: Arc<AtomicBool>,
|
||||
) {
|
||||
let fp = normfp(&our_fp);
|
||||
let eth_cache: EthCache = Arc::new(std::sync::Mutex::new(HashMap::new()));
|
||||
|
||||
// Pre-populate ETH cache for known contacts
|
||||
prefill_eth_cache(&db, &client, ð_cache).await;
|
||||
|
||||
// Try WebSocket first
|
||||
let ws_url = client.base_url
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://");
|
||||
let ws_url = format!("{}/v1/ws/{}", ws_url, fp);
|
||||
|
||||
loop {
|
||||
match tokio_tungstenite::connect_async(&ws_url).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
connected.store(true, Ordering::Relaxed);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Real-time connection established".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
use futures_util::StreamExt;
|
||||
let (_, mut read) = ws_stream.split();
|
||||
|
||||
while let Some(Ok(msg)) = read.next().await {
|
||||
match msg {
|
||||
tokio_tungstenite::tungstenite::Message::Binary(data) => {
|
||||
process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer);
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if json.get("type").and_then(|v| v.as_str()) == Some("missed_call") {
|
||||
let data = json.get("data").cloned().unwrap_or_default();
|
||||
let caller = data.get("caller_fp").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let ts = data.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let when = chrono::DateTime::from_timestamp(ts, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).format("%H:%M").to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("\u{1f4de} Missed call from {} at {}", &caller[..caller.len().min(12)], when),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
print!("\x07");
|
||||
} else if json.get("type").and_then(|v| v.as_str()) == Some("bot_message") {
|
||||
let from = json.get("from_name").or(json.get("from")).and_then(|v| v.as_str()).unwrap_or("bot");
|
||||
let text_content = json.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: format!("@{}", from),
|
||||
text: text_content.to_string(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
print!("\x07");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Connection lost, reconnecting...".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, sender_fp: None, timestamp: Local::now(),
|
||||
});
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
// Fallback to HTTP polling
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let raw_msgs = match client.poll_messages(&our_fp).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for raw in &raw_msgs {
|
||||
process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
267
warzone/crates/warzone-client/src/tui/types.rs
Normal file
267
warzone/crates/warzone-client/src/tui/types.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
/// Maximum file size: 10 MB.
|
||||
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
/// Chunk size: 64 KB.
|
||||
pub const CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
/// State for tracking an incoming chunked file transfer.
|
||||
#[derive(Clone)]
|
||||
pub struct PendingFileTransfer {
|
||||
pub filename: String,
|
||||
pub total_chunks: u32,
|
||||
pub received: u32,
|
||||
pub chunks: Vec<Option<Vec<u8>>>,
|
||||
pub sha256: String,
|
||||
pub file_size: u64,
|
||||
}
|
||||
|
||||
/// Receipt status for a sent message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ReceiptStatus {
|
||||
Sent,
|
||||
Delivered,
|
||||
Read,
|
||||
}
|
||||
|
||||
/// Active call information.
|
||||
#[derive(Clone)]
|
||||
pub struct CallInfo {
|
||||
pub peer_fp: String,
|
||||
pub peer_display: String,
|
||||
pub state: CallPhase,
|
||||
pub started_at: DateTime<Local>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum CallPhase {
|
||||
Calling, // we initiated, waiting for answer
|
||||
Ringing, // incoming call, waiting for user to accept/reject
|
||||
Active, // call connected
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub input: String,
|
||||
pub messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||
pub our_fp: String,
|
||||
pub peer_fp: Option<String>,
|
||||
pub server_url: String,
|
||||
pub should_quit: bool,
|
||||
pub cursor_pos: usize,
|
||||
pub last_dm_peer: Arc<Mutex<Option<String>>>,
|
||||
/// Track receipt status for messages we sent, keyed by message ID.
|
||||
pub receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
/// Pending incoming file transfers, keyed by file ID.
|
||||
pub pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
/// Our ETH address (derived from seed).
|
||||
pub our_eth: String,
|
||||
/// Current peer's ETH address (resolved on /peer set).
|
||||
pub peer_eth: Option<String>,
|
||||
/// Scroll offset from bottom (0 = pinned to newest).
|
||||
pub scroll_offset: usize,
|
||||
/// Whether the WebSocket connection is active.
|
||||
pub connected: Arc<AtomicBool>,
|
||||
/// Current call state: None=idle, Some(state)=active
|
||||
pub call_state: Option<CallInfo>,
|
||||
/// Message IDs for which we've already sent a Read receipt (avoid duplicates).
|
||||
pub read_receipts_sent: Arc<Mutex<std::collections::HashSet<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChatLine {
|
||||
pub sender: String,
|
||||
pub text: String,
|
||||
pub is_system: bool,
|
||||
pub is_self: bool,
|
||||
/// Message ID (for sent messages, used to track receipts).
|
||||
pub message_id: Option<String>,
|
||||
/// Sender's full fingerprint (for sending read receipts back).
|
||||
pub sender_fp: Option<String>,
|
||||
/// When this message was created/received.
|
||||
pub timestamp: DateTime<Local>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(our_fp: String, peer_fp: Option<String>, server_url: String) -> Self {
|
||||
// Derive ETH address from seed first (used in welcome messages)
|
||||
let our_eth = crate::keystore::load_seed_raw()
|
||||
.map(|seed| {
|
||||
let eth = warzone_protocol::ethereum::derive_eth_identity(&seed);
|
||||
eth.address.to_checksum()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let identity_display = if our_eth.is_empty() { our_fp.clone() } else { our_eth.clone() };
|
||||
|
||||
let messages = Arc::new(Mutex::new(vec![ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("You are {}", identity_display),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
}]));
|
||||
|
||||
if let Some(ref peer) = peer_fp {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Chatting with {}", peer),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
} else {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
|
||||
App {
|
||||
input: String::new(),
|
||||
messages,
|
||||
our_fp,
|
||||
peer_fp,
|
||||
server_url,
|
||||
should_quit: false,
|
||||
last_dm_peer: Arc::new(Mutex::new(None)),
|
||||
cursor_pos: 0,
|
||||
receipts: Arc::new(Mutex::new(HashMap::new())),
|
||||
pending_files: Arc::new(Mutex::new(HashMap::new())),
|
||||
our_eth,
|
||||
peer_eth: None,
|
||||
scroll_offset: 0,
|
||||
connected: Arc::new(AtomicBool::new(false)),
|
||||
call_state: None,
|
||||
read_receipts_sent: Arc::new(Mutex::new(std::collections::HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_message(&self, line: ChatLine) {
|
||||
self.messages.lock().unwrap().push(line);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normfp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[test]
|
||||
fn app_new_initializes_scroll_offset_to_zero() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_initializes_connected_to_false() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert!(!app.connected.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_creates_system_messages() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
assert!(msgs.len() >= 2);
|
||||
assert!(msgs[0].is_system);
|
||||
// First message shows ETH address (if seed exists) or fingerprint
|
||||
assert!(msgs[0].text.contains("You are"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_with_peer_shows_chatting_message() {
|
||||
let app = App::new("aabbcc".into(), Some("ddeeff".into()), "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
let has_chatting = msgs.iter().any(|m| m.text.contains("Chatting with") && m.text.contains("ddeeff"));
|
||||
assert!(has_chatting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_without_peer_shows_no_peer_message() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
let has_no_peer = msgs.iter().any(|m| m.text.contains("No peer set"));
|
||||
assert!(has_no_peer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatline_has_timestamp() {
|
||||
let line = ChatLine {
|
||||
sender: "test".into(),
|
||||
text: "hello".into(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
};
|
||||
// Timestamp should be within the last second
|
||||
let elapsed = Local::now().signed_duration_since(line.timestamp);
|
||||
assert!(elapsed.num_seconds() < 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_message_appends_to_list() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let initial_count = app.messages.lock().unwrap().len();
|
||||
app.add_message(ChatLine {
|
||||
sender: "test".into(),
|
||||
text: "new message".into(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
sender_fp: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
let new_count = app.messages.lock().unwrap().len();
|
||||
assert_eq!(new_count, initial_count + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normfp_strips_non_hex_and_lowercases() {
|
||||
assert_eq!(normfp("AA-BB-CC"), "aabbcc");
|
||||
assert_eq!(normfp("0x1234ABCD"), "01234abcd");
|
||||
assert_eq!(normfp("hello"), "e"); // only 'e' is hex
|
||||
assert_eq!(normfp("AABB"), "aabb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_cursor_pos_zero() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_should_quit_false() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert!(!app.should_quit);
|
||||
}
|
||||
}
|
||||
9
warzone/crates/warzone-mule/Cargo.toml
Normal file
9
warzone/crates/warzone-mule/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "warzone-mule"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
warzone-protocol = { path = "../warzone-protocol" }
|
||||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
1
warzone/crates/warzone-mule/src/lib.rs
Normal file
1
warzone/crates/warzone-mule/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Mule protocol implementation — Phase 4.
|
||||
4
warzone/crates/warzone-mule/src/main.rs
Normal file
4
warzone/crates/warzone-mule/src/main.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
fn main() {
|
||||
println!("warzone-mule: Phase 4 — not yet implemented");
|
||||
println!("See DESIGN.md section 4 for the mule protocol specification.");
|
||||
}
|
||||
48
warzone/crates/warzone-protocol/Cargo.toml
Normal file
48
warzone/crates/warzone-protocol/Cargo.toml
Normal file
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.47"
|
||||
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]
|
||||
# Crypto
|
||||
ed25519-dalek = { version = "2", features = ["serde", "rand_core"] }
|
||||
x25519-dalek = { version = "2", features = ["serde", "static_secrets"] }
|
||||
curve25519-dalek = "4"
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
|
||||
# Ethereum compatibility
|
||||
k256 = { version = "0.13", features = ["ecdsa", "serde"] }
|
||||
tiny-keccak = { version = "2", features = ["keccak"] }
|
||||
|
||||
# BIP39
|
||||
bip39 = "2"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bincode = "1"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
# Encoding
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# Memory safety
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
87
warzone/crates/warzone-protocol/src/crypto.rs
Normal file
87
warzone/crates/warzone-protocol/src/crypto.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
ChaCha20Poly1305, Nonce,
|
||||
};
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::errors::ProtocolError;
|
||||
|
||||
/// HKDF-SHA256 key derivation.
|
||||
pub fn hkdf_derive(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec<u8> {
|
||||
let salt = if salt.is_empty() { None } else { Some(salt) };
|
||||
let hk = Hkdf::<Sha256>::new(salt, ikm);
|
||||
let mut output = vec![0u8; len];
|
||||
hk.expand(info, &mut output)
|
||||
.expect("HKDF output length should be valid");
|
||||
output
|
||||
}
|
||||
|
||||
/// Encrypt with ChaCha20-Poly1305. Returns nonce (12 bytes) || ciphertext.
|
||||
pub fn aead_encrypt(key: &[u8; 32], plaintext: &[u8], aad: &[u8]) -> Vec<u8> {
|
||||
let cipher = ChaCha20Poly1305::new(key.into());
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, chacha20poly1305::aead::Payload { msg: plaintext, aad })
|
||||
.expect("encryption should not fail");
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce_bytes);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
result
|
||||
}
|
||||
|
||||
/// Decrypt ChaCha20-Poly1305. Input: nonce (12 bytes) || ciphertext.
|
||||
pub fn aead_decrypt(key: &[u8; 32], data: &[u8], aad: &[u8]) -> Result<Vec<u8>, ProtocolError> {
|
||||
if data.len() < 12 {
|
||||
return Err(ProtocolError::DecryptionFailed);
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
let cipher = ChaCha20Poly1305::new(key.into());
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
cipher
|
||||
.decrypt(nonce, chacha20poly1305::aead::Payload { msg: ciphertext, aad })
|
||||
.map_err(|_| ProtocolError::DecryptionFailed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aead_roundtrip() {
|
||||
let key = [42u8; 32];
|
||||
let plaintext = b"hello warzone";
|
||||
let aad = b"associated data";
|
||||
|
||||
let encrypted = aead_encrypt(&key, plaintext, aad);
|
||||
let decrypted = aead_decrypt(&key, &encrypted, aad).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aead_wrong_key_fails() {
|
||||
let key = [42u8; 32];
|
||||
let wrong_key = [99u8; 32];
|
||||
let encrypted = aead_encrypt(&key, b"secret", b"");
|
||||
assert!(aead_decrypt(&wrong_key, &encrypted, b"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aead_wrong_aad_fails() {
|
||||
let key = [42u8; 32];
|
||||
let encrypted = aead_encrypt(&key, b"secret", b"aad1");
|
||||
assert!(aead_decrypt(&key, &encrypted, b"aad2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hkdf_deterministic() {
|
||||
let a = hkdf_derive(b"input", b"salt", b"info", 32);
|
||||
let b = hkdf_derive(b"input", b"salt", b"info", 32);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
34
warzone/crates/warzone-protocol/src/errors.rs
Normal file
34
warzone/crates/warzone-protocol/src/errors.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProtocolError {
|
||||
#[error("invalid seed length")]
|
||||
InvalidSeedLength,
|
||||
|
||||
#[error("invalid mnemonic")]
|
||||
InvalidMnemonic,
|
||||
|
||||
#[error("invalid fingerprint format")]
|
||||
InvalidFingerprint,
|
||||
|
||||
#[error("invalid signature")]
|
||||
InvalidSignature,
|
||||
|
||||
#[error("pre-key signature verification failed")]
|
||||
PreKeySignatureInvalid,
|
||||
|
||||
#[error("X3DH key exchange failed: {0}")]
|
||||
X3DHFailed(String),
|
||||
|
||||
#[error("ratchet error: {0}")]
|
||||
RatchetError(String),
|
||||
|
||||
#[error("decryption failed")]
|
||||
DecryptionFailed,
|
||||
|
||||
#[error("message too old (exceeded max skip)")]
|
||||
MaxSkipExceeded,
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
SerializationError(String),
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
182
warzone/crates/warzone-protocol/src/identity.rs
Normal file
182
warzone/crates/warzone-protocol/src/identity.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
use x25519_dalek::StaticSecret;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
use crate::crypto::hkdf_derive;
|
||||
use crate::errors::ProtocolError;
|
||||
use crate::types::Fingerprint;
|
||||
|
||||
/// The root secret — 32 bytes from which all keys are derived.
|
||||
/// Displayed to users as a BIP39 mnemonic (24 words).
|
||||
#[derive(Zeroize, ZeroizeOnDrop)]
|
||||
pub struct Seed(pub [u8; 32]);
|
||||
|
||||
impl Seed {
|
||||
/// Generate a new random seed.
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes);
|
||||
Seed(bytes)
|
||||
}
|
||||
|
||||
/// Create seed from raw bytes.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Seed(bytes)
|
||||
}
|
||||
|
||||
/// Derive the full identity keypair from this seed.
|
||||
pub fn derive_identity(&self) -> IdentityKeyPair {
|
||||
// Ed25519 signing key: HKDF(seed, info="warzone-ed25519")
|
||||
let ed_bytes = hkdf_derive(&self.0, b"", b"warzone-ed25519", 32);
|
||||
let mut ed_seed = [0u8; 32];
|
||||
ed_seed.copy_from_slice(&ed_bytes);
|
||||
let signing = SigningKey::from_bytes(&ed_seed);
|
||||
ed_seed.zeroize();
|
||||
|
||||
// X25519 encryption key: HKDF(seed, info="warzone-x25519")
|
||||
let x_bytes = hkdf_derive(&self.0, b"", b"warzone-x25519", 32);
|
||||
let mut x_seed = [0u8; 32];
|
||||
x_seed.copy_from_slice(&x_bytes);
|
||||
let encryption = StaticSecret::from(x_seed);
|
||||
x_seed.zeroize();
|
||||
|
||||
IdentityKeyPair {
|
||||
signing,
|
||||
encryption,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to BIP39 mnemonic words.
|
||||
pub fn to_mnemonic(&self) -> String {
|
||||
crate::mnemonic::seed_to_mnemonic(&self.0)
|
||||
}
|
||||
|
||||
/// Recover seed from BIP39 mnemonic words.
|
||||
pub fn from_mnemonic(words: &str) -> Result<Self, ProtocolError> {
|
||||
let bytes = crate::mnemonic::mnemonic_to_seed(words)?;
|
||||
Ok(Seed(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// The full identity keypair derived from a seed.
|
||||
pub struct IdentityKeyPair {
|
||||
pub signing: SigningKey,
|
||||
pub encryption: StaticSecret,
|
||||
}
|
||||
|
||||
impl IdentityKeyPair {
|
||||
/// Get the public identity (safe to share).
|
||||
pub fn public_identity(&self) -> PublicIdentity {
|
||||
let verifying = self.signing.verifying_key();
|
||||
let encryption_pub = x25519_dalek::PublicKey::from(&self.encryption);
|
||||
let fingerprint = PublicIdentity::compute_fingerprint(&verifying);
|
||||
|
||||
PublicIdentity {
|
||||
signing: verifying,
|
||||
encryption: encryption_pub,
|
||||
fingerprint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The public portion of an identity — safe to share with anyone.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PublicIdentity {
|
||||
#[serde(with = "verifying_key_serde")]
|
||||
pub signing: VerifyingKey,
|
||||
#[serde(with = "public_key_serde")]
|
||||
pub encryption: x25519_dalek::PublicKey,
|
||||
pub fingerprint: Fingerprint,
|
||||
}
|
||||
|
||||
impl PublicIdentity {
|
||||
fn compute_fingerprint(key: &VerifyingKey) -> Fingerprint {
|
||||
let hash = Sha256::digest(key.as_bytes());
|
||||
let mut fp = [0u8; 16];
|
||||
fp.copy_from_slice(&hash[..16]);
|
||||
Fingerprint(fp)
|
||||
}
|
||||
}
|
||||
|
||||
// Serde helpers for dalek types (serialize as bytes)
|
||||
mod verifying_key_serde {
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S>(key: &VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_bytes(key.as_bytes())
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<VerifyingKey, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let bytes: Vec<u8> = Deserialize::deserialize(deserializer)?;
|
||||
let arr: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| serde::de::Error::custom("invalid key length"))?;
|
||||
VerifyingKey::from_bytes(&arr).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
mod public_key_serde {
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
use x25519_dalek::PublicKey;
|
||||
|
||||
pub fn serialize<S>(key: &PublicKey, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_bytes(key.as_bytes())
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<PublicKey, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let bytes: Vec<u8> = Deserialize::deserialize(deserializer)?;
|
||||
let arr: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| serde::de::Error::custom("invalid key length"))?;
|
||||
Ok(PublicKey::from(arr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_derivation() {
|
||||
let seed = Seed::from_bytes([42u8; 32]);
|
||||
let id1 = seed.derive_identity();
|
||||
let id2 = seed.derive_identity();
|
||||
assert_eq!(
|
||||
id1.signing.verifying_key().as_bytes(),
|
||||
id2.signing.verifying_key().as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnemonic_roundtrip() {
|
||||
let seed = Seed::generate();
|
||||
let words = seed.to_mnemonic();
|
||||
let recovered = Seed::from_mnemonic(&words).unwrap();
|
||||
assert_eq!(seed.0, recovered.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_display() {
|
||||
let seed = Seed::generate();
|
||||
let id = seed.derive_identity();
|
||||
let pub_id = id.public_identity();
|
||||
let fp_str = pub_id.fingerprint.to_string();
|
||||
// Format: xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx
|
||||
assert_eq!(fp_str.len(), 39);
|
||||
assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 7);
|
||||
}
|
||||
}
|
||||
15
warzone/crates/warzone-protocol/src/lib.rs
Normal file
15
warzone/crates/warzone-protocol/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
pub mod types;
|
||||
pub mod errors;
|
||||
pub mod identity;
|
||||
pub mod mnemonic;
|
||||
pub mod crypto;
|
||||
pub mod prekey;
|
||||
pub mod x3dh;
|
||||
pub mod ratchet;
|
||||
pub mod message;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
pub mod history;
|
||||
pub mod sender_keys;
|
||||
pub mod ethereum;
|
||||
pub mod friends;
|
||||
235
warzone/crates/warzone-protocol/src/message.rs
Normal file
235
warzone/crates/warzone-protocol/src/message.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ratchet::RatchetHeader;
|
||||
use crate::types::{Fingerprint, MessageId, SessionId};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum MessageType {
|
||||
Text,
|
||||
File,
|
||||
KeyExchange,
|
||||
Receipt,
|
||||
}
|
||||
|
||||
/// An encrypted message on the wire.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct WarzoneMessage {
|
||||
pub version: u8,
|
||||
pub id: MessageId,
|
||||
pub from: Fingerprint,
|
||||
pub to: Fingerprint,
|
||||
pub timestamp: i64,
|
||||
pub msg_type: MessageType,
|
||||
pub session_id: SessionId,
|
||||
pub ratchet_header: RatchetHeader,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Plaintext message content (inside the encrypted envelope).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum MessageContent {
|
||||
Text { body: String },
|
||||
File { filename: String, data: Vec<u8> },
|
||||
Receipt { message_id: MessageId },
|
||||
}
|
||||
|
||||
/// Receipt type: delivered (received + decrypted) or read (user viewed).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ReceiptType {
|
||||
Delivered,
|
||||
Read,
|
||||
}
|
||||
|
||||
/// Wire message format for transport between clients.
|
||||
/// Used by both CLI and WASM — MUST be identical for interop.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum WireMessage {
|
||||
/// First message to a peer: X3DH key exchange + first ratchet message.
|
||||
KeyExchange {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
sender_identity_encryption_key: [u8; 32],
|
||||
ephemeral_public: [u8; 32],
|
||||
used_one_time_pre_key_id: Option<u32>,
|
||||
ratchet_message: crate::ratchet::RatchetMessage,
|
||||
},
|
||||
/// Subsequent messages: ratchet-encrypted.
|
||||
Message {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
ratchet_message: crate::ratchet::RatchetMessage,
|
||||
},
|
||||
/// Delivery / read receipt (plaintext, not encrypted).
|
||||
Receipt {
|
||||
sender_fingerprint: String,
|
||||
message_id: String,
|
||||
receipt_type: ReceiptType,
|
||||
},
|
||||
/// File transfer header: announces an incoming chunked file.
|
||||
FileHeader {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
filename: String,
|
||||
file_size: u64,
|
||||
total_chunks: u32,
|
||||
sha256: String,
|
||||
},
|
||||
/// A single chunk of a file transfer (data is ratchet-encrypted).
|
||||
FileChunk {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
filename: String,
|
||||
chunk_index: u32,
|
||||
total_chunks: u32,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Group message encrypted with sender key (O(1) instead of O(N)).
|
||||
GroupSenderKey {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
group_name: String,
|
||||
generation: u32,
|
||||
counter: u32,
|
||||
ciphertext: Vec<u8>,
|
||||
},
|
||||
/// Sender key distribution: share your sender key with a group member.
|
||||
/// This is sent via 1:1 encrypted channel (wrapped in KeyExchange/Message).
|
||||
SenderKeyDistribution {
|
||||
sender_fingerprint: String,
|
||||
group_name: String,
|
||||
chain_key: [u8; 32],
|
||||
generation: u32,
|
||||
},
|
||||
/// Call signaling: SDP offers/answers, ICE candidates, call control.
|
||||
/// Routed through featherChat's E2E encrypted channel for WarzonePhone integration.
|
||||
CallSignal {
|
||||
id: String,
|
||||
sender_fingerprint: String,
|
||||
signal_type: CallSignalType,
|
||||
/// SDP offer/answer body, ICE candidate, or empty for hangup/reject.
|
||||
payload: String,
|
||||
/// Target peer (for 1:1) or group/room name (for group calls).
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Call signaling types for WarzonePhone integration.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum CallSignalType {
|
||||
/// Initiate a call (contains SDP offer or WZP connection params).
|
||||
Offer,
|
||||
/// Accept a call (contains SDP answer or WZP connection params).
|
||||
Answer,
|
||||
/// ICE candidate for NAT traversal.
|
||||
IceCandidate,
|
||||
/// Hang up / end call.
|
||||
Hangup,
|
||||
/// Reject incoming call.
|
||||
Reject,
|
||||
/// Call is ringing on the other side.
|
||||
Ringing,
|
||||
/// Peer is busy.
|
||||
Busy,
|
||||
}
|
||||
|
||||
/// Current wire protocol version.
|
||||
pub const WIRE_VERSION: u8 = 1;
|
||||
/// Magic bytes to identify versioned envelope: "WZ"
|
||||
pub const WIRE_MAGIC: [u8; 2] = [0x57, 0x5A];
|
||||
|
||||
/// Serialize a WireMessage with version envelope.
|
||||
/// Format: [0x57][0x5A][version: u8][length: u32 BE][bincode payload]
|
||||
pub fn serialize_envelope(msg: &WireMessage) -> Result<Vec<u8>, String> {
|
||||
let payload =
|
||||
bincode::serialize(msg).map_err(|e| format!("serialize: {}", e))?;
|
||||
let len = payload.len() as u32;
|
||||
let mut out = Vec::with_capacity(7 + payload.len());
|
||||
out.extend_from_slice(&WIRE_MAGIC);
|
||||
out.push(WIRE_VERSION);
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(&payload);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Deserialize a WireMessage, handling both envelope and legacy formats.
|
||||
/// - Envelope: [0x57][0x5A][version][length][payload]
|
||||
/// - Legacy: raw bincode (no envelope)
|
||||
pub fn deserialize_envelope(data: &[u8]) -> Result<WireMessage, String> {
|
||||
if data.len() >= 7 && data[0] == WIRE_MAGIC[0] && data[1] == WIRE_MAGIC[1] {
|
||||
let version = data[2];
|
||||
let len =
|
||||
u32::from_be_bytes([data[3], data[4], data[5], data[6]]) as usize;
|
||||
if version > WIRE_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported wire version {} (max {}). Please update your client.",
|
||||
version, WIRE_VERSION
|
||||
));
|
||||
}
|
||||
if data.len() < 7 + len {
|
||||
return Err("truncated envelope".to_string());
|
||||
}
|
||||
bincode::deserialize(&data[7..7 + len])
|
||||
.map_err(|e| format!("v{} deserialize: {}", version, e))
|
||||
} else {
|
||||
// Legacy: raw bincode
|
||||
bincode::deserialize(data)
|
||||
.map_err(|e| format!("legacy deserialize: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod envelope_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn envelope_roundtrip() {
|
||||
let msg = WireMessage::Receipt {
|
||||
sender_fingerprint: "abc123".to_string(),
|
||||
message_id: "msg-001".to_string(),
|
||||
receipt_type: ReceiptType::Delivered,
|
||||
};
|
||||
let envelope = serialize_envelope(&msg).unwrap();
|
||||
assert_eq!(&envelope[..2], &WIRE_MAGIC);
|
||||
assert_eq!(envelope[2], WIRE_VERSION);
|
||||
|
||||
let decoded = deserialize_envelope(&envelope).unwrap();
|
||||
match decoded {
|
||||
WireMessage::Receipt { message_id, .. } => {
|
||||
assert_eq!(message_id, "msg-001")
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_still_works() {
|
||||
let msg = WireMessage::Receipt {
|
||||
sender_fingerprint: "abc123".to_string(),
|
||||
message_id: "msg-002".to_string(),
|
||||
receipt_type: ReceiptType::Read,
|
||||
};
|
||||
let raw = bincode::serialize(&msg).unwrap();
|
||||
let decoded = deserialize_envelope(&raw).unwrap();
|
||||
match decoded {
|
||||
WireMessage::Receipt { message_id, .. } => {
|
||||
assert_eq!(message_id, "msg-002")
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_version_rejected() {
|
||||
let mut envelope = serialize_envelope(&WireMessage::Receipt {
|
||||
sender_fingerprint: "x".into(),
|
||||
message_id: "y".into(),
|
||||
receipt_type: ReceiptType::Delivered,
|
||||
})
|
||||
.unwrap();
|
||||
envelope[2] = 99; // fake future version
|
||||
let result = deserialize_envelope(&envelope);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("unsupported wire version"));
|
||||
}
|
||||
}
|
||||
37
warzone/crates/warzone-protocol/src/mnemonic.rs
Normal file
37
warzone/crates/warzone-protocol/src/mnemonic.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use bip39::Mnemonic;
|
||||
|
||||
use crate::errors::ProtocolError;
|
||||
|
||||
/// Encode 32 bytes as a BIP39 mnemonic (24 words).
|
||||
pub fn seed_to_mnemonic(seed: &[u8; 32]) -> String {
|
||||
// BIP39 with 256 bits of entropy = 24 words
|
||||
let mnemonic = Mnemonic::from_entropy(seed).expect("32 bytes is valid BIP39 entropy");
|
||||
mnemonic.to_string()
|
||||
}
|
||||
|
||||
/// Decode a BIP39 mnemonic back to 32 bytes.
|
||||
pub fn mnemonic_to_seed(words: &str) -> Result<[u8; 32], ProtocolError> {
|
||||
let mnemonic: Mnemonic = words.parse().map_err(|_| ProtocolError::InvalidMnemonic)?;
|
||||
let entropy = mnemonic.to_entropy();
|
||||
if entropy.len() != 32 {
|
||||
return Err(ProtocolError::InvalidSeedLength);
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&entropy);
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let seed = [0xab; 32];
|
||||
let words = seed_to_mnemonic(&seed);
|
||||
let word_count = words.split_whitespace().count();
|
||||
assert_eq!(word_count, 24);
|
||||
let recovered = mnemonic_to_seed(&words).unwrap();
|
||||
assert_eq!(seed, recovered);
|
||||
}
|
||||
}
|
||||
115
warzone/crates/warzone-protocol/src/prekey.rs
Normal file
115
warzone/crates/warzone-protocol/src/prekey.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use ed25519_dalek::{Signature, Signer, Verifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use crate::errors::ProtocolError;
|
||||
use crate::identity::IdentityKeyPair;
|
||||
|
||||
/// A signed pre-key (medium-term, rotated periodically).
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SignedPreKey {
|
||||
pub id: u32,
|
||||
pub public_key: [u8; 32],
|
||||
pub signature: Vec<u8>,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl SignedPreKey {
|
||||
/// Verify the signature against the identity signing key.
|
||||
pub fn verify(&self, identity_key: &ed25519_dalek::VerifyingKey) -> Result<(), ProtocolError> {
|
||||
let sig =
|
||||
Signature::from_slice(&self.signature).map_err(|_| ProtocolError::InvalidSignature)?;
|
||||
identity_key
|
||||
.verify(&self.public_key, &sig)
|
||||
.map_err(|_| ProtocolError::PreKeySignatureInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-time pre-key (used once, then discarded).
|
||||
pub struct OneTimePreKey {
|
||||
pub id: u32,
|
||||
pub secret: StaticSecret,
|
||||
pub public: PublicKey,
|
||||
}
|
||||
|
||||
/// The public portion of a one-time pre-key (sent to server).
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct OneTimePreKeyPublic {
|
||||
pub id: u32,
|
||||
pub public_key: [u8; 32],
|
||||
}
|
||||
|
||||
/// A full pre-key bundle that the server stores for a user.
|
||||
/// Fetched by others to initiate X3DH key exchange.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct PreKeyBundle {
|
||||
pub identity_key: [u8; 32], // Ed25519 verifying key bytes
|
||||
pub identity_encryption_key: [u8; 32], // X25519 public key bytes
|
||||
pub signed_pre_key: SignedPreKey,
|
||||
pub one_time_pre_key: Option<OneTimePreKeyPublic>,
|
||||
}
|
||||
|
||||
/// Generate a signed pre-key.
|
||||
pub fn generate_signed_pre_key(identity: &IdentityKeyPair, id: u32) -> (StaticSecret, SignedPreKey) {
|
||||
let secret = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let public = PublicKey::from(&secret);
|
||||
let signature = identity.signing.sign(public.as_bytes());
|
||||
|
||||
let spk = SignedPreKey {
|
||||
id,
|
||||
public_key: *public.as_bytes(),
|
||||
signature: signature.to_bytes().to_vec(),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
(secret, spk)
|
||||
}
|
||||
|
||||
/// Generate a batch of one-time pre-keys.
|
||||
pub fn generate_one_time_pre_keys(start_id: u32, count: u32) -> Vec<OneTimePreKey> {
|
||||
(start_id..start_id + count)
|
||||
.map(|id| {
|
||||
let secret = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let public = PublicKey::from(&secret);
|
||||
OneTimePreKey {
|
||||
id,
|
||||
secret,
|
||||
public,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::Seed;
|
||||
|
||||
#[test]
|
||||
fn signed_pre_key_verify() {
|
||||
let seed = Seed::generate();
|
||||
let identity = seed.derive_identity();
|
||||
let (_secret, spk) = generate_signed_pre_key(&identity, 1);
|
||||
let pub_id = identity.public_identity();
|
||||
assert!(spk.verify(&pub_id.signing).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_pre_key_reject_tampered() {
|
||||
let seed = Seed::generate();
|
||||
let identity = seed.derive_identity();
|
||||
let (_secret, mut spk) = generate_signed_pre_key(&identity, 1);
|
||||
spk.public_key[0] ^= 0xff; // tamper
|
||||
let pub_id = identity.public_identity();
|
||||
assert!(spk.verify(&pub_id.signing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_otpks() {
|
||||
let keys = generate_one_time_pre_keys(0, 10);
|
||||
assert_eq!(keys.len(), 10);
|
||||
// All public keys should be unique
|
||||
let pubs: Vec<_> = keys.iter().map(|k| *k.public.as_bytes()).collect();
|
||||
let unique: std::collections::HashSet<_> = pubs.iter().collect();
|
||||
assert_eq!(unique.len(), 10);
|
||||
}
|
||||
}
|
||||
390
warzone/crates/warzone-protocol/src/ratchet.rs
Normal file
390
warzone/crates/warzone-protocol/src/ratchet.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
//! Double Ratchet algorithm implementation.
|
||||
//! Follows Signal's Double Ratchet specification.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use crate::crypto::{aead_decrypt, aead_encrypt, hkdf_derive};
|
||||
use crate::errors::ProtocolError;
|
||||
|
||||
const MAX_SKIP: u32 = 1000;
|
||||
|
||||
/// Current serialization version for [`RatchetState`].
|
||||
const RATCHET_VERSION: u8 = 1;
|
||||
/// Magic byte to distinguish versioned from unversioned (legacy) data.
|
||||
const RATCHET_MAGIC: u8 = 0xFC;
|
||||
|
||||
/// A message produced by the ratchet.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RatchetMessage {
|
||||
pub header: RatchetHeader,
|
||||
pub ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Header included with each ratchet message.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RatchetHeader {
|
||||
/// Current DH ratchet public key.
|
||||
pub dh_public: [u8; 32],
|
||||
/// Number of messages in the previous sending chain.
|
||||
pub prev_chain_length: u32,
|
||||
/// Message number in the current sending chain.
|
||||
pub message_number: u32,
|
||||
}
|
||||
|
||||
/// The Double Ratchet state machine.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RatchetState {
|
||||
dh_self: Vec<u8>, // StaticSecret bytes (32)
|
||||
dh_remote: Option<[u8; 32]>,
|
||||
root_key: [u8; 32],
|
||||
chain_key_send: Option<[u8; 32]>,
|
||||
chain_key_recv: Option<[u8; 32]>,
|
||||
send_count: u32,
|
||||
recv_count: u32,
|
||||
prev_send_count: u32,
|
||||
skipped: BTreeMap<([u8; 32], u32), [u8; 32]>, // (dh_pub, n) -> message_key
|
||||
}
|
||||
|
||||
impl RatchetState {
|
||||
/// Initialize as Alice (initiator). Alice knows Bob's ratchet public key.
|
||||
pub fn init_alice(shared_secret: [u8; 32], bob_ratchet_pub: PublicKey) -> Self {
|
||||
let dh_self = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let dh_out = dh_self.diffie_hellman(&bob_ratchet_pub);
|
||||
|
||||
let (root_key, chain_key_send) = kdf_rk(&shared_secret, dh_out.as_bytes());
|
||||
|
||||
RatchetState {
|
||||
dh_self: dh_self.to_bytes().to_vec(),
|
||||
dh_remote: Some(*bob_ratchet_pub.as_bytes()),
|
||||
root_key,
|
||||
chain_key_send: Some(chain_key_send),
|
||||
chain_key_recv: None,
|
||||
send_count: 0,
|
||||
recv_count: 0,
|
||||
prev_send_count: 0,
|
||||
skipped: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize as Bob (responder). Bob uses his signed pre-key as initial ratchet key.
|
||||
pub fn init_bob(shared_secret: [u8; 32], our_ratchet_secret: StaticSecret) -> Self {
|
||||
RatchetState {
|
||||
dh_self: our_ratchet_secret.to_bytes().to_vec(),
|
||||
dh_remote: None,
|
||||
root_key: shared_secret,
|
||||
chain_key_send: None,
|
||||
chain_key_recv: None,
|
||||
send_count: 0,
|
||||
recv_count: 0,
|
||||
prev_send_count: 0,
|
||||
skipped: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get our current DH ratchet public key.
|
||||
fn dh_public(&self) -> PublicKey {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&self.dh_self);
|
||||
let secret = StaticSecret::from(bytes);
|
||||
PublicKey::from(&secret)
|
||||
}
|
||||
|
||||
fn dh_secret(&self) -> StaticSecret {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&self.dh_self);
|
||||
StaticSecret::from(bytes)
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext message.
|
||||
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<RatchetMessage, ProtocolError> {
|
||||
// If we don't have a sending chain yet (Bob's first message), do a DH ratchet step
|
||||
if self.chain_key_send.is_none() {
|
||||
if self.dh_remote.is_none() {
|
||||
return Err(ProtocolError::RatchetError(
|
||||
"no remote DH key and no sending chain".into(),
|
||||
));
|
||||
}
|
||||
self.dh_ratchet_step()?;
|
||||
}
|
||||
|
||||
let ck = self
|
||||
.chain_key_send
|
||||
.as_ref()
|
||||
.ok_or_else(|| ProtocolError::RatchetError("no sending chain".into()))?;
|
||||
|
||||
let (new_ck, message_key) = kdf_ck(ck);
|
||||
self.chain_key_send = Some(new_ck);
|
||||
|
||||
let header = RatchetHeader {
|
||||
dh_public: *self.dh_public().as_bytes(),
|
||||
prev_chain_length: self.prev_send_count,
|
||||
message_number: self.send_count,
|
||||
};
|
||||
|
||||
// AAD: serialized header
|
||||
let aad = bincode::serialize(&header)
|
||||
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
|
||||
let ciphertext = aead_encrypt(&message_key, plaintext, &aad);
|
||||
|
||||
self.send_count += 1;
|
||||
|
||||
Ok(RatchetMessage { header, ciphertext })
|
||||
}
|
||||
|
||||
/// Decrypt a received ratchet message.
|
||||
pub fn decrypt(&mut self, message: &RatchetMessage) -> Result<Vec<u8>, ProtocolError> {
|
||||
// Check skipped messages first
|
||||
let key = (message.header.dh_public, message.header.message_number);
|
||||
if let Some(mk) = self.skipped.remove(&key) {
|
||||
let aad = bincode::serialize(&message.header)
|
||||
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
|
||||
return aead_decrypt(&mk, &message.ciphertext, &aad);
|
||||
}
|
||||
|
||||
// If the message's DH key differs from what we have, perform DH ratchet
|
||||
let need_ratchet = match self.dh_remote {
|
||||
Some(ref remote) => *remote != message.header.dh_public,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if need_ratchet {
|
||||
// Skip any missed messages in the current receiving chain
|
||||
if self.chain_key_recv.is_some() {
|
||||
self.skip_messages(message.header.prev_chain_length)?;
|
||||
}
|
||||
|
||||
// DH ratchet step
|
||||
let their_pub = PublicKey::from(message.header.dh_public);
|
||||
|
||||
// New receiving chain
|
||||
let dh_recv = self.dh_secret().diffie_hellman(&their_pub);
|
||||
let (rk, ck_recv) = kdf_rk(&self.root_key, dh_recv.as_bytes());
|
||||
self.root_key = rk;
|
||||
self.chain_key_recv = Some(ck_recv);
|
||||
self.recv_count = 0;
|
||||
|
||||
// New sending chain
|
||||
self.prev_send_count = self.send_count;
|
||||
self.send_count = 0;
|
||||
let new_dh = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let dh_send = new_dh.diffie_hellman(&their_pub);
|
||||
let (rk2, ck_send) = kdf_rk(&self.root_key, dh_send.as_bytes());
|
||||
self.root_key = rk2;
|
||||
self.chain_key_send = Some(ck_send);
|
||||
self.dh_self = new_dh.to_bytes().to_vec();
|
||||
self.dh_remote = Some(message.header.dh_public);
|
||||
}
|
||||
|
||||
// Skip to the message number
|
||||
self.skip_messages(message.header.message_number)?;
|
||||
|
||||
// Derive message key
|
||||
let ck = self
|
||||
.chain_key_recv
|
||||
.as_ref()
|
||||
.ok_or_else(|| ProtocolError::RatchetError("no receiving chain".into()))?;
|
||||
let (new_ck, message_key) = kdf_ck(ck);
|
||||
self.chain_key_recv = Some(new_ck);
|
||||
self.recv_count += 1;
|
||||
|
||||
let aad = bincode::serialize(&message.header)
|
||||
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
|
||||
aead_decrypt(&message_key, &message.ciphertext, &aad)
|
||||
}
|
||||
|
||||
fn skip_messages(&mut self, until: u32) -> Result<(), ProtocolError> {
|
||||
if self.recv_count + MAX_SKIP < until {
|
||||
return Err(ProtocolError::MaxSkipExceeded);
|
||||
}
|
||||
if let Some(ref ck) = self.chain_key_recv.clone() {
|
||||
let dh_pub = self.dh_remote.unwrap_or([0u8; 32]);
|
||||
let mut current_ck = *ck;
|
||||
while self.recv_count < until {
|
||||
let (new_ck, mk) = kdf_ck(¤t_ck);
|
||||
self.skipped.insert((dh_pub, self.recv_count), mk);
|
||||
current_ck = new_ck;
|
||||
self.recv_count += 1;
|
||||
}
|
||||
self.chain_key_recv = Some(current_ck);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize with version prefix: `[MAGIC][VERSION][bincode data]`.
|
||||
///
|
||||
/// Use [`deserialize_versioned`](Self::deserialize_versioned) to restore.
|
||||
pub fn serialize_versioned(&self) -> Result<Vec<u8>, String> {
|
||||
let data = bincode::serialize(self)
|
||||
.map_err(|e| format!("serialize: {}", e))?;
|
||||
let mut out = Vec::with_capacity(2 + data.len());
|
||||
out.push(RATCHET_MAGIC);
|
||||
out.push(RATCHET_VERSION);
|
||||
out.extend_from_slice(&data);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Deserialize with version awareness. Handles:
|
||||
/// - Versioned format: `[0xFC][version][bincode]`
|
||||
/// - Legacy format: raw bincode (no prefix)
|
||||
pub fn deserialize_versioned(data: &[u8]) -> Result<Self, String> {
|
||||
if data.len() >= 2 && data[0] == RATCHET_MAGIC {
|
||||
let version = data[1];
|
||||
match version {
|
||||
1 => bincode::deserialize(&data[2..])
|
||||
.map_err(|e| format!("v1 deserialize: {}", e)),
|
||||
_ => Err(format!("unknown ratchet version: {}", version)),
|
||||
}
|
||||
} else {
|
||||
// Legacy: try raw bincode (pre-versioning data)
|
||||
bincode::deserialize(data)
|
||||
.map_err(|e| format!("legacy deserialize: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> {
|
||||
let their_pub = self
|
||||
.dh_remote
|
||||
.map(PublicKey::from)
|
||||
.ok_or_else(|| ProtocolError::RatchetError("no remote key for ratchet".into()))?;
|
||||
|
||||
self.prev_send_count = self.send_count;
|
||||
self.send_count = 0;
|
||||
|
||||
let new_dh = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let dh_out = new_dh.diffie_hellman(&their_pub);
|
||||
let (rk, ck_send) = kdf_rk(&self.root_key, dh_out.as_bytes());
|
||||
self.root_key = rk;
|
||||
self.chain_key_send = Some(ck_send);
|
||||
self.dh_self = new_dh.to_bytes().to_vec();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Root key KDF: derive new root key + chain key from DH output.
|
||||
fn kdf_rk(root_key: &[u8; 32], dh_output: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
let derived = hkdf_derive(dh_output, root_key, b"warzone-ratchet-rk", 64);
|
||||
let mut new_rk = [0u8; 32];
|
||||
let mut chain_key = [0u8; 32];
|
||||
new_rk.copy_from_slice(&derived[..32]);
|
||||
chain_key.copy_from_slice(&derived[32..]);
|
||||
(new_rk, chain_key)
|
||||
}
|
||||
|
||||
/// Chain key KDF: derive new chain key + message key.
|
||||
fn kdf_ck(chain_key: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
|
||||
let mk_bytes = hkdf_derive(chain_key, b"", b"warzone-ratchet-mk", 32);
|
||||
let ck_bytes = hkdf_derive(chain_key, b"", b"warzone-ratchet-ck", 32);
|
||||
let mut new_ck = [0u8; 32];
|
||||
let mut mk = [0u8; 32];
|
||||
new_ck.copy_from_slice(&ck_bytes);
|
||||
mk.copy_from_slice(&mk_bytes);
|
||||
(new_ck, mk)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_pair() -> (RatchetState, RatchetState) {
|
||||
let shared_secret = [42u8; 32];
|
||||
let bob_ratchet = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let bob_ratchet_pub = PublicKey::from(&bob_ratchet);
|
||||
|
||||
let alice = RatchetState::init_alice(shared_secret, bob_ratchet_pub);
|
||||
let bob = RatchetState::init_bob(shared_secret, bob_ratchet);
|
||||
(alice, bob)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_exchange() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
let msg = alice.encrypt(b"hello bob").unwrap();
|
||||
let plain = bob.decrypt(&msg).unwrap();
|
||||
assert_eq!(plain, b"hello bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidirectional() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
|
||||
let m1 = alice.encrypt(b"hello bob").unwrap();
|
||||
assert_eq!(bob.decrypt(&m1).unwrap(), b"hello bob");
|
||||
|
||||
let m2 = bob.encrypt(b"hello alice").unwrap();
|
||||
assert_eq!(alice.decrypt(&m2).unwrap(), b"hello alice");
|
||||
|
||||
let m3 = alice.encrypt(b"how are you?").unwrap();
|
||||
assert_eq!(bob.decrypt(&m3).unwrap(), b"how are you?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_messages_same_direction() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
|
||||
let m1 = alice.encrypt(b"one").unwrap();
|
||||
let m2 = alice.encrypt(b"two").unwrap();
|
||||
let m3 = alice.encrypt(b"three").unwrap();
|
||||
|
||||
assert_eq!(bob.decrypt(&m1).unwrap(), b"one");
|
||||
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
|
||||
assert_eq!(bob.decrypt(&m3).unwrap(), b"three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
|
||||
let m1 = alice.encrypt(b"one").unwrap();
|
||||
let m2 = alice.encrypt(b"two").unwrap();
|
||||
let m3 = alice.encrypt(b"three").unwrap();
|
||||
|
||||
// Deliver out of order
|
||||
assert_eq!(bob.decrypt(&m3).unwrap(), b"three");
|
||||
assert_eq!(bob.decrypt(&m1).unwrap(), b"one");
|
||||
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_serialize_roundtrip() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
let msg = alice.encrypt(b"test versioning").unwrap();
|
||||
|
||||
// Save alice with versioned format
|
||||
let serialized = alice.serialize_versioned().unwrap();
|
||||
assert_eq!(serialized[0], 0xFC); // magic byte
|
||||
assert_eq!(serialized[1], 1); // version 1
|
||||
|
||||
// Restore and use
|
||||
let mut restored = RatchetState::deserialize_versioned(&serialized).unwrap();
|
||||
let msg2 = restored.encrypt(b"after restore").unwrap();
|
||||
let plain = bob.decrypt(&msg).unwrap();
|
||||
assert_eq!(plain, b"test versioning");
|
||||
let plain2 = bob.decrypt(&msg2).unwrap();
|
||||
assert_eq!(plain2, b"after restore");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_deserialize_works() {
|
||||
let (alice, _) = make_pair();
|
||||
// Serialize with raw bincode (legacy format)
|
||||
let legacy = bincode::serialize(&alice).unwrap();
|
||||
// Should still deserialize with versioned reader
|
||||
let restored = RatchetState::deserialize_versioned(&legacy).unwrap();
|
||||
assert_eq!(bincode::serialize(&restored).unwrap(), legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_messages() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
for i in 0..100 {
|
||||
let msg = format!("message {}", i);
|
||||
let encrypted = alice.encrypt(msg.as_bytes()).unwrap();
|
||||
let decrypted = bob.decrypt(&encrypted).unwrap();
|
||||
assert_eq!(decrypted, msg.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
14
warzone/crates/warzone-protocol/src/session.rs
Normal file
14
warzone/crates/warzone-protocol/src/session.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ratchet::RatchetState;
|
||||
use crate::types::{Fingerprint, SessionId};
|
||||
|
||||
/// A session represents an ongoing encrypted conversation with a peer.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: SessionId,
|
||||
pub peer: Fingerprint,
|
||||
pub ratchet: RatchetState,
|
||||
pub created_at: i64,
|
||||
pub last_active: i64,
|
||||
}
|
||||
26
warzone/crates/warzone-protocol/src/store.rs
Normal file
26
warzone/crates/warzone-protocol/src/store.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! Storage trait definitions. Implementations live in server/client crates.
|
||||
|
||||
use crate::errors::ProtocolError;
|
||||
use crate::message::WarzoneMessage;
|
||||
use crate::prekey::{OneTimePreKey, SignedPreKey};
|
||||
use crate::session::Session;
|
||||
use crate::types::{Fingerprint, MessageId};
|
||||
|
||||
pub trait PreKeyStore {
|
||||
fn store_signed_pre_key(&mut self, key: SignedPreKey) -> Result<(), ProtocolError>;
|
||||
fn load_signed_pre_key(&self, id: u32) -> Result<Option<SignedPreKey>, ProtocolError>;
|
||||
fn store_one_time_pre_keys(&mut self, keys: Vec<OneTimePreKey>) -> Result<(), ProtocolError>;
|
||||
fn take_one_time_pre_key(&mut self, id: u32) -> Result<Option<OneTimePreKey>, ProtocolError>;
|
||||
fn count_one_time_pre_keys(&self) -> Result<usize, ProtocolError>;
|
||||
}
|
||||
|
||||
pub trait SessionStore {
|
||||
fn load_session(&self, peer: &Fingerprint) -> Result<Option<Session>, ProtocolError>;
|
||||
fn store_session(&mut self, session: &Session) -> Result<(), ProtocolError>;
|
||||
}
|
||||
|
||||
pub trait MessageQueue {
|
||||
fn queue_message(&mut self, msg: &WarzoneMessage) -> Result<(), ProtocolError>;
|
||||
fn fetch_messages(&self, recipient: &Fingerprint) -> Result<Vec<WarzoneMessage>, ProtocolError>;
|
||||
fn delete_message(&mut self, id: &MessageId) -> Result<(), ProtocolError>;
|
||||
}
|
||||
72
warzone/crates/warzone-protocol/src/types.rs
Normal file
72
warzone/crates/warzone-protocol/src/types.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Truncated SHA-256 hash of the public signing key (16 bytes).
|
||||
/// The primary identity of a user — displayed as hex groups.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Fingerprint(pub [u8; 16]);
|
||||
|
||||
impl fmt::Display for Fingerprint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}:{:04x}",
|
||||
u16::from_be_bytes([self.0[0], self.0[1]]),
|
||||
u16::from_be_bytes([self.0[2], self.0[3]]),
|
||||
u16::from_be_bytes([self.0[4], self.0[5]]),
|
||||
u16::from_be_bytes([self.0[6], self.0[7]]),
|
||||
u16::from_be_bytes([self.0[8], self.0[9]]),
|
||||
u16::from_be_bytes([self.0[10], self.0[11]]),
|
||||
u16::from_be_bytes([self.0[12], self.0[13]]),
|
||||
u16::from_be_bytes([self.0[14], self.0[15]]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Fingerprint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Fingerprint({})", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Fingerprint {
|
||||
pub fn from_hex(s: &str) -> Result<Self, crate::errors::ProtocolError> {
|
||||
let clean: String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let bytes = hex::decode(&clean)
|
||||
.map_err(|_| crate::errors::ProtocolError::InvalidFingerprint)?;
|
||||
if bytes.len() < 16 {
|
||||
return Err(crate::errors::ProtocolError::InvalidFingerprint);
|
||||
}
|
||||
let mut fp = [0u8; 16];
|
||||
fp.copy_from_slice(&bytes[..16]);
|
||||
Ok(Fingerprint(fp))
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique device identifier (derived from seed + device index).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct DeviceId(pub u32);
|
||||
|
||||
/// Unique session identifier.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SessionId(pub uuid::Uuid);
|
||||
|
||||
impl SessionId {
|
||||
pub fn new() -> Self {
|
||||
SessionId(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique message identifier.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MessageId(pub uuid::Uuid);
|
||||
|
||||
impl MessageId {
|
||||
pub fn new() -> Self {
|
||||
MessageId(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
303
warzone/crates/warzone-protocol/src/x3dh.rs
Normal file
303
warzone/crates/warzone-protocol/src/x3dh.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! X3DH (Extended Triple Diffie-Hellman) key agreement.
|
||||
//! Follows Signal's X3DH specification.
|
||||
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::crypto::hkdf_derive;
|
||||
use crate::errors::ProtocolError;
|
||||
use crate::identity::IdentityKeyPair;
|
||||
use crate::prekey::PreKeyBundle;
|
||||
|
||||
/// Result of initiating X3DH (Alice's side).
|
||||
pub struct X3DHInitResult {
|
||||
/// The shared secret (32 bytes), used to initialize the Double Ratchet.
|
||||
pub shared_secret: [u8; 32],
|
||||
/// Alice's ephemeral public key (sent to Bob).
|
||||
pub ephemeral_public: PublicKey,
|
||||
/// Which one-time pre-key was used (if any).
|
||||
pub used_one_time_pre_key_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// Initiate X3DH key exchange (Alice's side).
|
||||
///
|
||||
/// Alice fetches Bob's pre-key bundle from the server, performs four DH
|
||||
/// operations, and derives a shared secret.
|
||||
pub fn initiate(
|
||||
our_identity: &IdentityKeyPair,
|
||||
their_bundle: &PreKeyBundle,
|
||||
) -> Result<X3DHInitResult, ProtocolError> {
|
||||
// Verify the signed pre-key signature
|
||||
let their_identity = ed25519_dalek::VerifyingKey::from_bytes(
|
||||
&their_bundle.identity_key,
|
||||
)
|
||||
.map_err(|_| ProtocolError::X3DHFailed("invalid identity key".into()))?;
|
||||
their_bundle
|
||||
.signed_pre_key
|
||||
.verify(&their_identity)
|
||||
.map_err(|_| ProtocolError::X3DHFailed("signed pre-key verification failed".into()))?;
|
||||
|
||||
let ephemeral_secret = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let ephemeral_public = PublicKey::from(&ephemeral_secret);
|
||||
|
||||
let their_spk = PublicKey::from(their_bundle.signed_pre_key.public_key);
|
||||
let their_identity_x25519 = PublicKey::from(their_bundle.identity_encryption_key);
|
||||
|
||||
// DH1: our_identity_x25519 * their_signed_pre_key
|
||||
let dh1 = our_identity.encryption.diffie_hellman(&their_spk);
|
||||
|
||||
// DH2: our_ephemeral * their_identity_x25519
|
||||
let dh2 = ephemeral_secret.diffie_hellman(&their_identity_x25519);
|
||||
|
||||
// DH3: our_ephemeral * their_signed_pre_key
|
||||
let dh3 = ephemeral_secret.diffie_hellman(&their_spk);
|
||||
|
||||
// DH4: our_ephemeral * their_one_time_pre_key (if available)
|
||||
let mut dh_concat = Vec::with_capacity(128);
|
||||
dh_concat.extend_from_slice(dh1.as_bytes());
|
||||
dh_concat.extend_from_slice(dh2.as_bytes());
|
||||
dh_concat.extend_from_slice(dh3.as_bytes());
|
||||
|
||||
let used_otpk_id = if let Some(ref otpk) = their_bundle.one_time_pre_key {
|
||||
let their_otpk = PublicKey::from(otpk.public_key);
|
||||
let dh4 = ephemeral_secret.diffie_hellman(&their_otpk);
|
||||
dh_concat.extend_from_slice(dh4.as_bytes());
|
||||
Some(otpk.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// KDF: derive 32-byte shared secret
|
||||
let mut shared_secret = [0u8; 32];
|
||||
let derived = hkdf_derive(&dh_concat, b"", b"warzone-x3dh", 32);
|
||||
shared_secret.copy_from_slice(&derived);
|
||||
dh_concat.zeroize();
|
||||
|
||||
Ok(X3DHInitResult {
|
||||
shared_secret,
|
||||
ephemeral_public,
|
||||
used_one_time_pre_key_id: used_otpk_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Respond to X3DH key exchange (Bob's side).
|
||||
///
|
||||
/// Bob receives Alice's ephemeral public key and performs the same DH
|
||||
/// operations to derive the same shared secret.
|
||||
pub fn respond(
|
||||
our_identity: &IdentityKeyPair,
|
||||
our_signed_pre_key_secret: &StaticSecret,
|
||||
our_one_time_pre_key_secret: Option<&StaticSecret>,
|
||||
their_identity_x25519: &PublicKey,
|
||||
their_ephemeral_public: &PublicKey,
|
||||
) -> Result<[u8; 32], ProtocolError> {
|
||||
let their_eph = *their_ephemeral_public;
|
||||
|
||||
// DH1: our_signed_pre_key * their_identity_x25519
|
||||
let dh1 = our_signed_pre_key_secret.diffie_hellman(their_identity_x25519);
|
||||
|
||||
// DH2: our_identity_x25519 * their_ephemeral
|
||||
let dh2 = our_identity.encryption.diffie_hellman(&their_eph);
|
||||
|
||||
// DH3: their_ephemeral * our_signed_pre_key
|
||||
let dh3 = our_signed_pre_key_secret.diffie_hellman(&their_eph);
|
||||
|
||||
let mut dh_concat = Vec::with_capacity(128);
|
||||
dh_concat.extend_from_slice(dh1.as_bytes());
|
||||
dh_concat.extend_from_slice(dh2.as_bytes());
|
||||
dh_concat.extend_from_slice(dh3.as_bytes());
|
||||
|
||||
if let Some(otpk) = our_one_time_pre_key_secret {
|
||||
let dh4 = otpk.diffie_hellman(&their_eph);
|
||||
dh_concat.extend_from_slice(dh4.as_bytes());
|
||||
}
|
||||
|
||||
let mut shared_secret = [0u8; 32];
|
||||
let derived = hkdf_derive(&dh_concat, b"", b"warzone-x3dh", 32);
|
||||
shared_secret.copy_from_slice(&derived);
|
||||
dh_concat.zeroize();
|
||||
|
||||
Ok(shared_secret)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::Seed;
|
||||
use crate::prekey::{generate_one_time_pre_keys, generate_signed_pre_key};
|
||||
|
||||
#[test]
|
||||
fn x3dh_shared_secret_matches() {
|
||||
let alice_seed = Seed::generate();
|
||||
let alice_id = alice_seed.derive_identity();
|
||||
|
||||
let bob_seed = Seed::generate();
|
||||
let bob_id = bob_seed.derive_identity();
|
||||
|
||||
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
|
||||
let bob_otpks = generate_one_time_pre_keys(0, 1);
|
||||
let bob_pub = bob_id.public_identity();
|
||||
|
||||
let alice_pub = alice_id.public_identity();
|
||||
|
||||
let 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: Some(crate::prekey::OneTimePreKeyPublic {
|
||||
id: bob_otpks[0].id,
|
||||
public_key: *bob_otpks[0].public.as_bytes(),
|
||||
}),
|
||||
};
|
||||
|
||||
let alice_result = initiate(&alice_id, &bundle).unwrap();
|
||||
let bob_secret = respond(
|
||||
&bob_id,
|
||||
&bob_spk_secret,
|
||||
Some(&bob_otpks[0].secret),
|
||||
&alice_pub.encryption,
|
||||
&alice_result.ephemeral_public,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
34
warzone/crates/warzone-server/Cargo.toml
Normal file
34
warzone/crates/warzone-server/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "warzone-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
warzone-protocol = { path = "../warzone-protocol" }
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
sled.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
rand.workspace = true
|
||||
futures-util = "0.3"
|
||||
ed25519-dalek.workspace = true
|
||||
bincode.workspace = true
|
||||
sha2.workspace = true
|
||||
reqwest = { workspace = true, features = ["rustls-tls", "json"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! Auth enforcement middleware: axum extractor that validates bearer tokens.
|
||||
//!
|
||||
//! Reads `Authorization: Bearer <token>` from request headers, validates via
|
||||
//! [`crate::routes::auth::validate_token`], and returns the authenticated
|
||||
//! fingerprint or a 401 rejection.
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extractor that validates a bearer token and provides the authenticated fingerprint.
|
||||
///
|
||||
/// Place this as the **first** parameter in any handler that requires authentication.
|
||||
/// The extractor will reject the request with 401 if the token is missing or invalid.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// async fn my_handler(
|
||||
/// auth: AuthFingerprint,
|
||||
/// State(state): State<AppState>,
|
||||
/// ) -> impl IntoResponse {
|
||||
/// let fp = auth.fingerprint; // guaranteed valid
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
pub struct AuthFingerprint {
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[axum::async_trait]
|
||||
impl FromRequestParts<AppState> for AuthFingerprint {
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let header = parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
let token = match header {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Err(AuthError::MissingToken),
|
||||
};
|
||||
|
||||
match crate::routes::auth::validate_token(&state.db.tokens, &token) {
|
||||
Some(fingerprint) => Ok(AuthFingerprint { fingerprint }),
|
||||
None => Err(AuthError::InvalidToken),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejection type for [`AuthFingerprint`] extractor failures.
|
||||
pub enum AuthError {
|
||||
/// No `Authorization: Bearer <token>` header was present (or it was empty).
|
||||
MissingToken,
|
||||
/// The token was present but did not pass validation (expired or unknown).
|
||||
InvalidToken,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match self {
|
||||
AuthError::MissingToken => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or empty Authorization: Bearer <token> header",
|
||||
),
|
||||
AuthError::InvalidToken => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid or expired token",
|
||||
),
|
||||
};
|
||||
(status, axum::Json(serde_json::json!({ "error": msg }))).into_response()
|
||||
}
|
||||
}
|
||||
282
warzone/crates/warzone-server/src/botfather.rs
Normal file
282
warzone/crates/warzone-server/src/botfather.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
//! Built-in BotFather: processes messages to @botfather and manages bot lifecycle.
|
||||
//!
|
||||
//! Supports: /start, /newbot, /mybots, /deletebot, /help
|
||||
//! Runs as a server-side handler — no external process needed.
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const BOTFATHER_FP: &str = "00000000000000000b0ffa00e000000f";
|
||||
|
||||
/// Check if a message is destined for BotFather and handle it.
|
||||
/// Called from deliver_or_queue when the recipient is the BotFather fingerprint.
|
||||
/// Returns true if handled (message consumed).
|
||||
pub async fn handle_botfather_message(state: &AppState, from_fp: &str, message: &[u8]) -> bool {
|
||||
if !state.bots_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to parse as plaintext bot_message JSON
|
||||
let bot_msg: serde_json::Value = match serde_json::from_slice(message) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false, // Encrypted messages can't be processed by built-in handler
|
||||
};
|
||||
|
||||
if bot_msg.get("type").and_then(|v| v.as_str()) != Some("bot_message") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let text = bot_msg
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let from_name = bot_msg
|
||||
.get("from_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(from_fp);
|
||||
|
||||
tracing::info!(
|
||||
"BotFather: message from {} ({}): {}",
|
||||
from_fp,
|
||||
from_name,
|
||||
text
|
||||
);
|
||||
|
||||
let response = match text {
|
||||
"/start" | "/help" => {
|
||||
"Welcome to BotFather! I can help you create and manage bots.\n\n\
|
||||
Commands:\n\
|
||||
/newbot - Create a new bot\n\
|
||||
/mybots - List your bots\n\
|
||||
/deletebot <name> - Delete a bot\n\
|
||||
/token <name> - Get bot token\n\
|
||||
/help - Show this message"
|
||||
.to_string()
|
||||
}
|
||||
t if t.starts_with("/newbot") => handle_newbot(state, from_fp, t).await,
|
||||
t if t.starts_with("/deletebot") => handle_deletebot(state, from_fp, t).await,
|
||||
"/mybots" => handle_mybots(state, from_fp).await,
|
||||
t if t.starts_with("/token") => handle_token(state, from_fp, t).await,
|
||||
_ => "I don't understand that command. Try /help".to_string(),
|
||||
};
|
||||
|
||||
// Send response back to the user
|
||||
send_botfather_reply(state, from_fp, &response).await;
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle_newbot(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||
// Parse: /newbot <name>
|
||||
let name = text.strip_prefix("/newbot").unwrap_or("").trim();
|
||||
if name.is_empty() {
|
||||
return "Usage: /newbot <botname>\n\nExample: /newbot WeatherBot\n\n\
|
||||
The name must end with 'bot' or 'Bot'."
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if name.len() > 32 || name.len() < 3 {
|
||||
return "Bot name must be 3-32 characters.".to_string();
|
||||
}
|
||||
|
||||
let name_lower = name.to_lowercase();
|
||||
if !name_lower.ends_with("bot") {
|
||||
return "Bot name must end with 'bot' or 'Bot'. Example: WeatherBot, my_bot".to_string();
|
||||
}
|
||||
|
||||
// Check if alias is taken
|
||||
let alias_key = format!("a:{}", name_lower);
|
||||
if state
|
||||
.db
|
||||
.aliases
|
||||
.get(alias_key.as_bytes())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return format!(
|
||||
"Sorry, @{} is already taken. Try a different name.",
|
||||
name_lower
|
||||
);
|
||||
}
|
||||
|
||||
// Generate fingerprint and token
|
||||
let fp_bytes: [u8; 16] = rand::random();
|
||||
let fp = hex::encode(fp_bytes);
|
||||
let token_rand: [u8; 16] = rand::random();
|
||||
let token = format!("{}:{}", &fp[..16], hex::encode(token_rand));
|
||||
|
||||
// Store bot info
|
||||
let bot_info = serde_json::json!({
|
||||
"name": name,
|
||||
"fingerprint": fp,
|
||||
"token": token,
|
||||
"owner": owner_fp,
|
||||
"e2e": false,
|
||||
"created_at": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
|
||||
let bot_key = format!("bot:{}", token);
|
||||
let _ = state.db.tokens.insert(
|
||||
bot_key.as_bytes(),
|
||||
serde_json::to_vec(&bot_info).unwrap_or_default(),
|
||||
);
|
||||
let fp_key = format!("bot_fp:{}", fp);
|
||||
let _ = state.db.tokens.insert(fp_key.as_bytes(), token.as_bytes());
|
||||
|
||||
// Register alias (all 3 keys needed for resolve_alias to work)
|
||||
let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes());
|
||||
let alias_record = serde_json::json!({
|
||||
"alias": name_lower,
|
||||
"fingerprint": fp,
|
||||
"recovery_key": "",
|
||||
"registered_at": chrono::Utc::now().timestamp(),
|
||||
"last_active": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let _ = state.db.aliases.insert(format!("rec:{}", name_lower).as_bytes(), serde_json::to_vec(&alias_record).unwrap_or_default());
|
||||
|
||||
tracing::info!(
|
||||
"BotFather: created bot @{} for owner {}",
|
||||
name_lower,
|
||||
owner_fp
|
||||
);
|
||||
|
||||
format!(
|
||||
"Done! Your new bot @{} is ready.\n\n\
|
||||
Token: {}\n\n\
|
||||
Keep this token secret! Use it to access the Bot API.\n\n\
|
||||
API endpoint: /v1/bot/{}/getUpdates",
|
||||
name_lower, token, token
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_deletebot(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||
let name = text
|
||||
.strip_prefix("/deletebot")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if name.is_empty() {
|
||||
return "Usage: /deletebot <botname>".to_string();
|
||||
}
|
||||
|
||||
// Find the bot
|
||||
let alias_key = format!("a:{}", name);
|
||||
let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() {
|
||||
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||
None => return format!("Bot @{} not found.", name),
|
||||
};
|
||||
|
||||
// Get bot info to verify ownership
|
||||
let token_key = format!("bot_fp:{}", bot_fp);
|
||||
let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() {
|
||||
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||
None => return format!("Bot @{} not found in registry.", name),
|
||||
};
|
||||
|
||||
let bot_key = format!("bot:{}", token);
|
||||
if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() {
|
||||
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
|
||||
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if owner != owner_fp && owner != "system" {
|
||||
return format!("You don't own @{}. Only the owner can delete it.", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete everything
|
||||
let _ = state.db.tokens.remove(bot_key.as_bytes());
|
||||
let _ = state.db.tokens.remove(token_key.as_bytes());
|
||||
let _ = state.db.aliases.remove(alias_key.as_bytes());
|
||||
let _ = state
|
||||
.db
|
||||
.aliases
|
||||
.remove(format!("fp:{}", bot_fp).as_bytes());
|
||||
let _ = state.db.keys.remove(bot_fp.as_bytes());
|
||||
|
||||
tracing::info!("BotFather: deleted bot @{} by owner {}", name, owner_fp);
|
||||
format!("Bot @{} has been deleted.", name)
|
||||
}
|
||||
|
||||
async fn handle_mybots(state: &AppState, owner_fp: &str) -> String {
|
||||
let mut bots = Vec::new();
|
||||
|
||||
for item in state.db.tokens.iter().flatten() {
|
||||
let key = String::from_utf8_lossy(&item.0).to_string();
|
||||
if !key.starts_with("bot:") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&item.1) {
|
||||
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if owner == owner_fp {
|
||||
let name = info.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let e2e = info.get("e2e").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let mode = if e2e { "E2E" } else { "plaintext" };
|
||||
bots.push(format!(" @{} ({})", name.to_lowercase(), mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bots.is_empty() {
|
||||
"You have no bots. Use /newbot <name> to create one.".to_string()
|
||||
} else {
|
||||
format!("Your bots ({}):\n{}", bots.len(), bots.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_token(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||
let name = text
|
||||
.strip_prefix("/token")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if name.is_empty() {
|
||||
return "Usage: /token <botname>".to_string();
|
||||
}
|
||||
|
||||
let alias_key = format!("a:{}", name);
|
||||
let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() {
|
||||
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||
None => return format!("Bot @{} not found.", name),
|
||||
};
|
||||
|
||||
let token_key = format!("bot_fp:{}", bot_fp);
|
||||
let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() {
|
||||
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||
None => return format!("Token not found for @{}.", name),
|
||||
};
|
||||
|
||||
// Verify ownership
|
||||
let bot_key = format!("bot:{}", token);
|
||||
if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() {
|
||||
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
|
||||
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if owner != owner_fp {
|
||||
return format!("You don't own @{}.", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format!("Token for @{}:\n{}", name, token)
|
||||
}
|
||||
|
||||
/// Send a reply from BotFather to a user.
|
||||
async fn send_botfather_reply(state: &AppState, to_fp: &str, text: &str) {
|
||||
let msg = serde_json::json!({
|
||||
"type": "bot_message",
|
||||
"id": uuid::Uuid::new_v4().to_string(),
|
||||
"from": BOTFATHER_FP,
|
||||
"from_name": "BotFather",
|
||||
"text": text,
|
||||
"timestamp": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let msg_bytes = serde_json::to_vec(&msg).unwrap_or_default();
|
||||
|
||||
// Deliver directly (don't go through deliver_or_queue to avoid recursion)
|
||||
if !state.push_to_client(to_fp, &msg_bytes).await {
|
||||
// Queue for offline pickup
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = state.db.messages.insert(key.as_bytes(), msg_bytes);
|
||||
}
|
||||
}
|
||||
2
warzone/crates/warzone-server/src/config.rs
Normal file
2
warzone/crates/warzone-server/src/config.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Server configuration — currently handled via CLI args in main.rs.
|
||||
// This module will be used when file-based configuration is added.
|
||||
41
warzone/crates/warzone-server/src/db.rs
Normal file
41
warzone/crates/warzone-server/src/db.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct Database {
|
||||
pub keys: sled::Tree,
|
||||
pub messages: 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,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn open(data_dir: &str) -> Result<Self> {
|
||||
let db = sled::open(data_dir)?;
|
||||
let keys = db.open_tree("keys")?;
|
||||
let messages = db.open_tree("messages")?;
|
||||
let groups = db.open_tree("groups")?;
|
||||
let aliases = db.open_tree("aliases")?;
|
||||
let tokens = db.open_tree("tokens")?;
|
||||
let calls = db.open_tree("calls")?;
|
||||
let missed_calls = db.open_tree("missed_calls")?;
|
||||
let friends = db.open_tree("friends")?;
|
||||
let eth_addresses = db.open_tree("eth_addresses")?;
|
||||
Ok(Database {
|
||||
keys,
|
||||
messages,
|
||||
groups,
|
||||
aliases,
|
||||
tokens,
|
||||
calls,
|
||||
missed_calls,
|
||||
friends,
|
||||
eth_addresses,
|
||||
_db: db,
|
||||
})
|
||||
}
|
||||
}
|
||||
21
warzone/crates/warzone-server/src/errors.rs
Normal file
21
warzone/crates/warzone-server/src/errors.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// Wraps anyhow::Error into an axum-compatible error response.
|
||||
pub struct AppError(pub anyhow::Error);
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
tracing::error!("{:#}", self.0);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Into<anyhow::Error>> From<E> for AppError {
|
||||
fn from(err: E) -> Self {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
warzone/crates/warzone-server/src/lib.rs
Normal file
8
warzone/crates/warzone-server/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod auth_middleware;
|
||||
pub mod botfather;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod errors;
|
||||
pub mod federation;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
254
warzone/crates/warzone-server/src/main.rs
Normal file
254
warzone/crates/warzone-server/src/main.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use clap::Parser;
|
||||
|
||||
mod botfather;
|
||||
pub mod auth_middleware;
|
||||
mod config;
|
||||
mod db;
|
||||
mod errors;
|
||||
mod federation;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "warzone-server", about = "Warzone messenger server")]
|
||||
struct Cli {
|
||||
/// Address to bind to
|
||||
#[arg(short, long, default_value = "0.0.0.0:7700")]
|
||||
bind: String,
|
||||
|
||||
/// Database directory
|
||||
#[arg(short, long, default_value = "./warzone-data")]
|
||||
data_dir: String,
|
||||
|
||||
/// Federation config file (JSON). Enables server-to-server message relay.
|
||||
#[arg(short, long)]
|
||||
federation: Option<String>,
|
||||
|
||||
/// Enable bot API (disabled by default)
|
||||
#[arg(long, default_value = "false")]
|
||||
enable_bots: bool,
|
||||
|
||||
/// System bots config file (JSON array). Bots are auto-created on startup.
|
||||
#[arg(long)]
|
||||
bots_config: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,tower_http=debug".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
tracing::info!("Warzone server starting on {}", cli.bind);
|
||||
|
||||
let mut state = state::AppState::new(&cli.data_dir)?;
|
||||
|
||||
// Reload active calls from DB
|
||||
{
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let mut loaded = 0u32;
|
||||
let mut expired = 0u32;
|
||||
for item in state.db.calls.iter().flatten() {
|
||||
if let Ok(call) = serde_json::from_slice::<state::CallState>(&item.1) {
|
||||
match call.status {
|
||||
state::CallStatus::Ringing | state::CallStatus::Active => {
|
||||
if now - call.created_at > 86400 {
|
||||
let mut ended = call.clone();
|
||||
ended.status = state::CallStatus::Ended;
|
||||
ended.ended_at = Some(now);
|
||||
let _ = state.db.calls.insert(
|
||||
&item.0,
|
||||
serde_json::to_vec(&ended).unwrap_or_default(),
|
||||
);
|
||||
expired += 1;
|
||||
} else {
|
||||
state.active_calls.lock().await.insert(call.call_id.clone(), call);
|
||||
loaded += 1;
|
||||
}
|
||||
}
|
||||
_ => {} // Ended calls stay in DB but not in memory
|
||||
}
|
||||
}
|
||||
}
|
||||
if loaded > 0 || expired > 0 {
|
||||
tracing::info!("Calls: loaded {} active, expired {} stale", loaded, expired);
|
||||
}
|
||||
}
|
||||
|
||||
// Load federation config if provided
|
||||
if let Some(ref fed_path) = cli.federation {
|
||||
let fed_config = federation::load_config(fed_path)?;
|
||||
tracing::info!(
|
||||
"Federation enabled: server_id={}, peer={}@{}",
|
||||
fed_config.server_id, fed_config.peer.id, fed_config.peer.url
|
||||
);
|
||||
let handle = federation::FederationHandle::new(fed_config);
|
||||
state.federation = Some(handle);
|
||||
}
|
||||
|
||||
// Enable bot API if requested
|
||||
state.bots_enabled = cli.enable_bots;
|
||||
if cli.enable_bots {
|
||||
tracing::info!("Bot API enabled");
|
||||
|
||||
// Auto-create BotFather if it doesn't exist
|
||||
let botfather_fp = "00000000000000000b0ffa00e000000f";
|
||||
let botfather_key = format!("bot_fp:{}", botfather_fp);
|
||||
if state.db.tokens.get(botfather_key.as_bytes()).ok().flatten().is_none() {
|
||||
let token = format!("botfather:{}", hex::encode(rand::random::<[u8; 16]>()));
|
||||
let bot_info = serde_json::json!({
|
||||
"name": "BotFather",
|
||||
"fingerprint": botfather_fp,
|
||||
"token": token,
|
||||
"owner": "system",
|
||||
"e2e": false,
|
||||
"created_at": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let key = format!("bot:{}", token);
|
||||
let _ = state.db.tokens.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default());
|
||||
let _ = state.db.tokens.insert(botfather_key.as_bytes(), token.as_bytes());
|
||||
// Register alias
|
||||
let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather");
|
||||
tracing::info!("BotFather created: @botfather (token: {})", token);
|
||||
} else {
|
||||
tracing::info!("BotFather already exists");
|
||||
}
|
||||
// Always ensure alias exists (may have been lost on data wipe)
|
||||
let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather");
|
||||
// Store proper AliasRecord so resolve_alias works
|
||||
let bf_record = serde_json::json!({
|
||||
"alias": "botfather",
|
||||
"fingerprint": botfather_fp,
|
||||
"recovery_key": "",
|
||||
"registered_at": chrono::Utc::now().timestamp(),
|
||||
"last_active": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let _ = state.db.aliases.insert(b"rec:botfather", serde_json::to_vec(&bf_record).unwrap_or_default());
|
||||
|
||||
// Load system bots from config file
|
||||
if let Some(ref bots_path) = cli.bots_config {
|
||||
match std::fs::read_to_string(bots_path) {
|
||||
Ok(data) => {
|
||||
if let Ok(bots) = serde_json::from_str::<Vec<serde_json::Value>>(&data) {
|
||||
for bot in &bots {
|
||||
let name = bot.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let desc = bot.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if name.is_empty() { continue; }
|
||||
|
||||
let alias = name.to_lowercase();
|
||||
let alias_key = format!("a:{}", alias);
|
||||
|
||||
// Check if already exists
|
||||
let existing_fp = state.db.aliases.get(alias_key.as_bytes())
|
||||
.ok().flatten()
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string());
|
||||
|
||||
let fp = if let Some(ref efp) = existing_fp {
|
||||
// Bot exists — just ensure alias record is intact
|
||||
efp.clone()
|
||||
} else {
|
||||
// Create new bot
|
||||
let fp_bytes: [u8; 16] = rand::random();
|
||||
let fp = hex::encode(fp_bytes);
|
||||
let token_rand: [u8; 16] = rand::random();
|
||||
let token = format!("{}:{}", &fp[..16], hex::encode(token_rand));
|
||||
|
||||
let bot_info = serde_json::json!({
|
||||
"name": name,
|
||||
"fingerprint": fp,
|
||||
"token": token,
|
||||
"owner": "system",
|
||||
"description": desc,
|
||||
"system_bot": true,
|
||||
"e2e": false,
|
||||
"created_at": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let _ = state.db.tokens.insert(format!("bot:{}", token).as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default());
|
||||
let _ = state.db.tokens.insert(format!("bot_fp:{}", fp).as_bytes(), token.as_bytes());
|
||||
let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes());
|
||||
tracing::info!("System bot @{} created (token: {})", alias, token);
|
||||
fp
|
||||
};
|
||||
|
||||
// Always ensure alias record exists
|
||||
let rec = serde_json::json!({
|
||||
"alias": alias,
|
||||
"fingerprint": fp,
|
||||
"recovery_key": "",
|
||||
"registered_at": chrono::Utc::now().timestamp(),
|
||||
"last_active": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let _ = state.db.aliases.insert(format!("rec:{}", alias).as_bytes(), serde_json::to_vec(&rec).unwrap_or_default());
|
||||
}
|
||||
tracing::info!("Loaded {} system bots from {}", bots.len(), bots_path);
|
||||
|
||||
// Write tokens to file for easy access
|
||||
let tokens_path = format!("{}/bot-tokens.txt", cli.data_dir);
|
||||
let mut token_lines = Vec::new();
|
||||
for bot in &bots {
|
||||
let name = bot.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if name.is_empty() { continue; }
|
||||
let alias = name.to_lowercase();
|
||||
if let Some(fp_bytes) = state.db.aliases.get(format!("a:{}", alias).as_bytes()).ok().flatten() {
|
||||
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
|
||||
if let Some(tok_bytes) = state.db.tokens.get(format!("bot_fp:{}", fp).as_bytes()).ok().flatten() {
|
||||
let tok = String::from_utf8_lossy(&tok_bytes).to_string();
|
||||
token_lines.push(format!("{}={}", alias.to_uppercase(), tok));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !token_lines.is_empty() {
|
||||
let _ = std::fs::write(&tokens_path, token_lines.join("\n") + "\n");
|
||||
tracing::info!("Bot tokens written to {}", tokens_path);
|
||||
}
|
||||
|
||||
// Store bot list in DB for welcome screen
|
||||
let bot_list: Vec<serde_json::Value> = bots.iter().map(|b| {
|
||||
serde_json::json!({
|
||||
"name": b.get("name").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"description": b.get("description").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
})
|
||||
}).collect();
|
||||
let _ = state.db.tokens.insert(b"system:bot_list", serde_json::to_vec(&bot_list).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("Failed to load bots config '{}': {}", bots_path, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn federation outgoing WS connection if enabled
|
||||
if let Some(ref fed) = state.federation {
|
||||
let handle = fed.clone();
|
||||
let fed_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
federation::outgoing_ws_loop(handle, fed_state).await;
|
||||
});
|
||||
}
|
||||
|
||||
let cors = tower_http::cors::CorsLayer::new()
|
||||
.allow_origin(tower_http::cors::Any)
|
||||
.allow_methods(tower_http::cors::Any)
|
||||
.allow_headers(tower_http::cors::Any);
|
||||
|
||||
let app = axum::Router::new()
|
||||
.merge(routes::web_router())
|
||||
.nest("/v1", routes::router())
|
||||
.layer(cors)
|
||||
.layer(tower::limit::ConcurrencyLimitLayer::new(200))
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
|
||||
tracing::info!("Listening on {}", cli.bind);
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
|
||||
|
||||
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 })))
|
||||
}
|
||||
66
warzone/crates/warzone-server/src/routes/health.rs
Normal file
66
warzone/crates/warzone-server/src/routes/health.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use axum::{extract::ConnectInfo, http::HeaderMap, routing::get, Json, Router};
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/whoami", get(whoami))
|
||||
}
|
||||
|
||||
async fn health() -> Json<serde_json::Value> {
|
||||
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)
|
||||
}
|
||||
199
warzone/crates/warzone-server/src/routes/keys.rs
Normal file
199
warzone/crates/warzone-server/src/routes/keys.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/keys/register", post(register_keys))
|
||||
.route("/keys/replenish", post(replenish_otpks))
|
||||
.route("/keys/list", get(list_keys))
|
||||
.route("/keys/:fingerprint", get(get_bundle))
|
||||
.route("/keys/:fingerprint/otpk-count", get(otpk_count))
|
||||
.route("/keys/:fingerprint/devices", get(list_devices))
|
||||
}
|
||||
|
||||
/// Debug endpoint: list all registered fingerprints.
|
||||
async fn list_keys(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let keys: Vec<String> = state
|
||||
.db
|
||||
.keys
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.ok()
|
||||
.and_then(|(k, _)| String::from_utf8(k.to_vec()).ok())
|
||||
})
|
||||
.collect();
|
||||
tracing::info!("Listed {} registered keys", keys.len());
|
||||
Json(serde_json::json!({ "keys": keys, "count": keys.len() }))
|
||||
}
|
||||
|
||||
/// Normalize fingerprint: strip colons, lowercase.
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterRequest {
|
||||
fingerprint: String,
|
||||
#[serde(default)]
|
||||
device_id: Option<String>,
|
||||
bundle: Vec<u8>,
|
||||
#[serde(default)]
|
||||
eth_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RegisterResponse {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
async fn register_keys(
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Json<RegisterResponse> {
|
||||
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 })
|
||||
}
|
||||
|
||||
async fn get_bundle(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
|
||||
let key = normalize_fp(&fingerprint);
|
||||
tracing::info!("get_bundle: raw path='{}', normalized='{}'", fingerprint, key);
|
||||
|
||||
// Debug: list what's in the DB
|
||||
let all_keys: Vec<String> = state.db.keys.iter()
|
||||
.filter_map(|r| r.ok().and_then(|(k, _)| String::from_utf8(k.to_vec()).ok()))
|
||||
.collect();
|
||||
tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys);
|
||||
|
||||
// Check if this fingerprint registered locally (has a device: entry)
|
||||
let device_prefix = format!("device:{}:", key);
|
||||
let is_local = state.db.keys.scan_prefix(device_prefix.as_bytes()).next().is_some();
|
||||
|
||||
// For remote clients, always proxy from the federation peer (bundles may change)
|
||||
if !is_local {
|
||||
if let Some(ref federation) = state.federation {
|
||||
if let Some(bundle_bytes) = federation.fetch_remote_bundle(&key).await {
|
||||
tracing::info!("get_bundle: PROXIED from federation peer for {}", key);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bundle_bytes),
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match state.db.keys.get(key.as_bytes()) {
|
||||
Ok(Some(data)) => {
|
||||
tracing::info!("get_bundle: FOUND {} bytes for {} (local={})", data.len(), key, is_local);
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
|
||||
})))
|
||||
}
|
||||
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() }))
|
||||
}
|
||||
145
warzone/crates/warzone-server/src/routes/messages.rs
Normal file
145
warzone/crates/warzone-server/src/routes/messages.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use warzone_protocol::message::WireMessage;
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Try to extract the message ID from raw WireMessage bytes (envelope or legacy).
|
||||
fn extract_message_id(data: &[u8]) -> Option<String> {
|
||||
if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) {
|
||||
match wire {
|
||||
WireMessage::KeyExchange { id, .. } => Some(id),
|
||||
WireMessage::Message { id, .. } => Some(id),
|
||||
WireMessage::FileHeader { id, .. } => Some(id),
|
||||
WireMessage::FileChunk { id, .. } => Some(id),
|
||||
WireMessage::Receipt { message_id, .. } => Some(message_id),
|
||||
WireMessage::GroupSenderKey { id, .. } => Some(id),
|
||||
WireMessage::SenderKeyDistribution { sender_fingerprint, group_name, .. } => {
|
||||
Some(format!("skd:{}:{}", sender_fingerprint, group_name))
|
||||
}
|
||||
WireMessage::CallSignal { id, .. } => Some(id),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch the alias TTL for a fingerprint (renew on authenticated action).
|
||||
pub fn renew_alias_ttl(db: &sled::Tree, fp: &str) {
|
||||
let alias_key = format!("fp:{}", fp);
|
||||
if let Ok(Some(alias_bytes)) = db.get(alias_key.as_bytes()) {
|
||||
let alias = String::from_utf8_lossy(&alias_bytes).to_string();
|
||||
let rec_key = format!("rec:{}", alias);
|
||||
if let Ok(Some(rec_data)) = db.get(rec_key.as_bytes()) {
|
||||
if let Ok(mut record) = serde_json::from_slice::<serde_json::Value>(&rec_data) {
|
||||
if let Some(obj) = record.as_object_mut() {
|
||||
obj.insert("last_active".into(), serde_json::json!(chrono::Utc::now().timestamp()));
|
||||
if let Ok(updated) = serde_json::to_vec(&record) {
|
||||
let _ = db.insert(rec_key.as_bytes(), updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/messages/send", post(send_message))
|
||||
.route("/messages/poll/:fingerprint", get(poll_messages))
|
||||
.route("/messages/:id/ack", delete(ack_message))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendRequest {
|
||||
to: String,
|
||||
#[serde(default)]
|
||||
from: Option<String>,
|
||||
message: Vec<u8>,
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SendRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let to = normalize_fp(&req.to);
|
||||
|
||||
// Dedup: if we have already seen this message ID, silently drop it
|
||||
if let Some(msg_id) = extract_message_id(&req.message) {
|
||||
if state.dedup.check_and_insert(&msg_id) {
|
||||
tracing::debug!("Dedup: dropping duplicate message {}", msg_id);
|
||||
return Ok(Json(serde_json::json!({ "ok": true })));
|
||||
}
|
||||
}
|
||||
|
||||
let delivered = state.deliver_or_queue(&to, &req.message).await;
|
||||
if delivered {
|
||||
tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len());
|
||||
} else {
|
||||
tracing::info!("Queued message for {} ({} bytes)", to, req.message.len());
|
||||
}
|
||||
|
||||
// Renew sender's alias TTL (sending = authenticated action)
|
||||
if let Some(ref from) = req.from {
|
||||
renew_alias_ttl(&state.db.aliases, &normalize_fp(from));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Poll fetches all queued messages and deletes them from the server.
|
||||
/// This is store-and-forward: once delivered, the server drops them.
|
||||
async fn poll_messages(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> AppResult<Json<Vec<String>>> {
|
||||
let prefix = format!("queue:{}", normalize_fp(&fingerprint));
|
||||
let mut messages = Vec::new();
|
||||
let mut keys_to_delete = Vec::new();
|
||||
|
||||
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
||||
let (key, value) = item?;
|
||||
messages.push(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&value,
|
||||
));
|
||||
keys_to_delete.push(key);
|
||||
}
|
||||
|
||||
// Delete after collecting (fetch-and-delete)
|
||||
for key in &keys_to_delete {
|
||||
state.db.messages.remove(key)?;
|
||||
}
|
||||
|
||||
if !messages.is_empty() {
|
||||
tracing::info!(
|
||||
"Delivered {} message(s) to {}, deleted from queue",
|
||||
messages.len(),
|
||||
normalize_fp(&fingerprint)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(messages))
|
||||
}
|
||||
|
||||
/// Explicit ack endpoint (for future use with selective delivery).
|
||||
async fn ack_message(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
state.db.messages.remove(id.as_bytes())?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
44
warzone/crates/warzone-server/src/routes/mod.rs
Normal file
44
warzone/crates/warzone-server/src/routes/mod.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
mod aliases;
|
||||
pub mod auth;
|
||||
pub mod bot;
|
||||
mod calls;
|
||||
mod devices;
|
||||
mod federation;
|
||||
mod friends;
|
||||
mod groups;
|
||||
mod health;
|
||||
mod keys;
|
||||
pub mod messages;
|
||||
mod presence;
|
||||
mod resolve;
|
||||
mod web;
|
||||
mod ws;
|
||||
mod wzp;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.merge(health::routes())
|
||||
.merge(keys::routes())
|
||||
.merge(messages::routes())
|
||||
.merge(groups::routes())
|
||||
.merge(aliases::routes())
|
||||
.merge(auth::routes())
|
||||
.merge(ws::routes())
|
||||
.merge(calls::routes())
|
||||
.merge(devices::routes())
|
||||
.merge(presence::routes())
|
||||
.merge(wzp::routes())
|
||||
.merge(friends::routes())
|
||||
.merge(federation::routes())
|
||||
.merge(bot::routes())
|
||||
.merge(resolve::routes())
|
||||
}
|
||||
|
||||
/// Web UI router (served at root, outside /v1)
|
||||
pub fn web_router() -> Router<AppState> {
|
||||
web::routes()
|
||||
}
|
||||
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" })))
|
||||
}
|
||||
2591
warzone/crates/warzone-server/src/routes/web.rs
Normal file
2591
warzone/crates/warzone-server/src/routes/web.rs
Normal file
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,
|
||||
})))
|
||||
}
|
||||
414
warzone/crates/warzone-server/src/state.rs
Normal file
414
warzone/crates/warzone-server/src/state.rs
Normal file
@@ -0,0 +1,414 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
/// Maximum WebSocket connections per fingerprint (multi-device cap).
|
||||
const MAX_WS_PER_FINGERPRINT: usize = 5;
|
||||
|
||||
/// Maximum number of message IDs to track for deduplication.
|
||||
const DEDUP_CAPACITY: usize = 10_000;
|
||||
|
||||
/// Per-connection sender: messages are pushed here for instant delivery.
|
||||
pub type WsSender = mpsc::UnboundedSender<Vec<u8>>;
|
||||
|
||||
/// Metadata for a single connected device.
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceConnection {
|
||||
pub device_id: String,
|
||||
pub sender: WsSender,
|
||||
pub connected_at: i64,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// Connected clients: fingerprint → list of device connections (multiple devices).
|
||||
pub type Connections = Arc<Mutex<HashMap<String, Vec<DeviceConnection>>>>;
|
||||
|
||||
/// Bounded dedup tracker: FIFO eviction when capacity is exceeded.
|
||||
#[derive(Clone)]
|
||||
pub struct DedupTracker {
|
||||
seen: Arc<std::sync::Mutex<HashSet<String>>>,
|
||||
order: Arc<std::sync::Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl DedupTracker {
|
||||
pub fn new() -> Self {
|
||||
DedupTracker {
|
||||
seen: Arc::new(std::sync::Mutex::new(HashSet::with_capacity(DEDUP_CAPACITY))),
|
||||
order: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(DEDUP_CAPACITY))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this ID was already seen (i.e. it is a duplicate).
|
||||
/// If new, inserts it and evicts the oldest if over capacity.
|
||||
pub fn check_and_insert(&self, id: &str) -> bool {
|
||||
let mut seen = self.seen.lock().unwrap();
|
||||
if seen.contains(id) {
|
||||
return true; // duplicate
|
||||
}
|
||||
let mut order = self.order.lock().unwrap();
|
||||
if seen.len() >= DEDUP_CAPACITY {
|
||||
if let Some(oldest) = order.pop_front() {
|
||||
seen.remove(&oldest);
|
||||
}
|
||||
}
|
||||
seen.insert(id.to_string());
|
||||
order.push_back(id.to_string());
|
||||
false // not a duplicate
|
||||
}
|
||||
}
|
||||
|
||||
/// Call lifecycle status.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum CallStatus {
|
||||
Ringing,
|
||||
Active,
|
||||
Ended,
|
||||
}
|
||||
|
||||
/// Server-side state for an active or recently ended call.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CallState {
|
||||
pub call_id: String,
|
||||
pub caller_fp: String,
|
||||
pub callee_fp: String,
|
||||
pub group_name: Option<String>,
|
||||
pub room_id: Option<String>,
|
||||
pub status: CallStatus,
|
||||
pub created_at: i64,
|
||||
pub answered_at: Option<i64>,
|
||||
pub ended_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub connections: Connections,
|
||||
pub dedup: DedupTracker,
|
||||
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
|
||||
pub federation: Option<crate::federation::FederationHandle>,
|
||||
pub bots_enabled: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(data_dir: &str) -> anyhow::Result<Self> {
|
||||
let db = Database::open(data_dir)?;
|
||||
Ok(AppState {
|
||||
db: Arc::new(db),
|
||||
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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user