155 Commits

Author SHA1 Message Date
Siavash Sameni
7c248442c2 feat: /testcall /testecho /testmic — local audio diagnostics
- /testcall: plays 3 tones (440/880/660Hz) to test speaker
- /testecho: mic → speaker loopback with 100ms delay (/stopecho to end)
- /testmic: records 3 seconds, plays back (tests both mic + speaker)

No relay or peer needed — pure local browser audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:38:26 +04:00
Siavash Sameni
5ae87be316 fix: remove --auth-url from wzp-relay (wzp-web doesn't send auth to relay)
wzp-web connects to relay via QUIC and does crypto handshake directly,
but relay with --auth-url expects AuthToken first → handshake fails.
Auth at relay level will be re-added when wzp-web learns to forward tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:29:50 +04:00
Siavash Sameni
f698b25fad fix: remove --auth-url from wzp-web (variants handle auth differently)
The 'full' variant sends a key exchange as first WS message, not auth.
The 'pure' variant sends raw PCM immediately, no auth.
Only ws/ws-fec/ws-full variants send auth JSON.

With auth removed, wzp-web accepts all WS connections. Auth is
still enforced on the relay (--auth-url) for direct connections.
Caddy provides access control at the TLS layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:19:43 +04:00
Siavash Sameni
7924871559 fix: set __WZP_BASE_URL before loading variant scripts (WASM path resolution)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:13:03 +04:00
Siavash Sameni
8a4f0ef8ee v0.0.47: integrate 6 WZP audio variants into featherChat calls
startAudio() now dynamically loads the selected WZP client variant:
- /audio-variant [pure|hybrid|full|ws|ws-fec|ws-full]
- Loads variant JS from wzp-web's /audio/js/ path via Caddy
- Falls back to inline pure implementation if variant fails to load
- Variant persisted in localStorage across sessions
- Call bar shows active variant: "In call [ws-fec] with 0x..."

Variants:
  pure     — raw PCM over WS (bridge needed, no WASM)
  hybrid   — raw PCM + WASM FEC over WS (bridge needed)
  full     — WebTransport + FEC + crypto (no bridge, future)
  ws       — WZP protocol over WS (relay direct)
  ws-fec   — WZP + WASM FEC over WS (relay direct)
  ws-full  — WZP + FEC + E2E crypto over WS (relay direct)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:02:43 +04:00
Siavash Sameni
561f2d6978 feat: variant testing — 6 subdomains + Caddy wildcard cert
- v1-v6.voip.manko.yoga → each maps to a WZP client variant
- Caddyfile.test: wildcard *.voip.manko.yoga with CF DNS cert
- scripts/test-variants.sh: --setup creates DNS + swaps Caddyfile
- --teardown cleans up DNS + restores original
- --check verifies all 6 respond HTTP 200
- All variants join same room for cross-variant audio testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:01:38 +04:00
Siavash Sameni
da3cdd7234 feat: integrate wzp-web-variants, remove --tls from wzp-web
- wzp-web runs plain HTTP behind Caddy (no --tls)
- deploy-chat.sh clones feature/wzp-web-variants for warzone-phone
- Three audio variants: ?variant=pure|hybrid|full
- Auth kept on both wzp-relay and wzp-web

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:07:21 +04:00
Siavash Sameni
cc76004655 fix: use public HTTPS git URLs, remove SSH key upload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:53:25 +04:00
Siavash Sameni
9af5ec96b5 feat: deploy-chat.sh — full Hetzner deploy for chat.manko.yoga
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:51:49 +04:00
Siavash Sameni
02471b28ba feat: start-voip.sh — update DNS locally + start Docker stack
- Removed dns-updater Docker sidecar (curl not available in alpine)
- scripts/start-voip.sh: updates DNS then docker compose up
- update-dns.sh: supports --once flag, runs locally with curl
- All CF API calls forced to IPv4 (-4 flag)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:39:23 +04:00
Siavash Sameni
74af18463e fix: install curl in dns-updater container
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:36:42 +04:00
Siavash Sameni
b22200e3be feat: dynamic DNS updater sidecar (auto-updates A + AAAA every 5min)
- update-dns.sh: detects public IPv4/IPv6, upserts CF records
- Runs on container start + every 5 minutes
- Only updates if IP actually changed (skips if unchanged)
- python:3-alpine container with curl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:34:05 +04:00
Siavash Sameni
850944944d revert: Caddy back to bridge network (host mode breaks OrbStack)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:26:12 +04:00
Siavash Sameni
47030a3b29 fix: Caddy host network mode for real client IPs
- Caddy now uses network_mode: host (sees real IPv4/IPv6)
- All backend services on fixed IPs (172.28.0.10/20/30)
- Caddyfile uses IPs instead of Docker DNS names
- /myip now returns actual client IP, not Docker gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:19:23 +04:00
Siavash Sameni
cac812665c fix: Caddyfile adds X-Real-IP header + trusted_proxies config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:12:57 +04:00
Siavash Sameni
f272a82faf feat: /myip command + /v1/whoami endpoint
Server:
- GET /v1/whoami returns client IP, IPv4/IPv6 classification
- Detects proxy via X-Forwarded-For, X-Real-IP, Via headers
- Shows proxy details when behind reverse proxy (Caddy etc)
- ConnectInfo enabled for direct socket address

Web client:
- /myip, /whatsmyip, /ip — shows your IP + proxy info
- Useful for testing IPv4/IPv6 connectivity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:11:06 +04:00
Siavash Sameni
11133cf968 fix: use fixed IP for wzp-relay (wzp-web can't resolve hostnames)
wzp-web --relay only accepts IP:port, not Docker hostnames.
Fixed IP 172.28.0.10 on backend network with explicit subnet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:03:43 +04:00
Siavash Sameni
8b00144b2f fix: force IPv4 in Caddy build (Docker lacks IPv6 during build)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:56:53 +04:00
Siavash Sameni
bf9594f1de fix: use debian:trixie-slim runtime (match rust:latest glibc)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:54:44 +04:00
Siavash Sameni
366ab30988 fix: install cmake in wzp Docker build (opus codec dependency)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:41:57 +04:00
Siavash Sameni
fb29eb0fce fix: build WASM before server (include_str! needs wasm-pkg)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:31:31 +04:00
Siavash Sameni
33c39c6541 deploy: add deploy-voip.sh + fix Rust version (use latest)
- scripts/deploy-voip.sh: full Hetzner cx23 + Docker + CF DNS deploy
  --create: provision VPS, install Docker
  --dns: update CF A + AAAA records
  --deploy: upload source, docker compose up
  --test: 6 HTTP checks + TLS + IPv6
  --all: end-to-end in one command
- Dockerfiles: use rust:latest (time crate needs 1.88+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:26:46 +04:00
Siavash Sameni
3d387e5821 fix: copy warzone-protocol into wzp build (deps/featherchat path)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:21:40 +04:00
Siavash Sameni
38f992c284 fix: bump Docker Rust to 1.85 (edition 2024 support for wzp-proto)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:19:50 +04:00
Siavash Sameni
59d68b2a5e fix: build Caddy with CF plugin from source (ARM64 compat)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:14:33 +04:00
Siavash Sameni
f33ac1cad8 deploy: Docker Compose stack with Caddy + Cloudflare TLS
Full production stack via docker compose:
- Caddy reverse proxy with Cloudflare DNS-01 TLS certs
- warzone-server (featherChat API + web UI)
- wzp-relay (QUIC audio SFU)
- wzp-web (browser WS ↔ QUIC bridge)

Architecture:
  Internet → Caddy (443/TLS) → voip.manko.yoga
    /*       → warzone-server:7700
    /audio/* → wzp-web:8080

Files:
- docker-compose.yml: main stack (4 services)
- docker-compose.ipv6.yml: IPv6 overlay
- Caddyfile: Cloudflare DNS challenge + reverse proxy
- Dockerfile.server: featherChat multi-stage build
- Dockerfile.wzp: wzp-relay + wzp-web multi-stage build
- .env.example: DNS records for dev/staging/prod
- test-stack.sh: smoke test (8 checks)
- .dockerignore: excludes target/, .git/, etc.

Deployment targets:
  dev:  172.16.81.135
  ipv6: 2a0d:3344:692c:2500:14f2:5885:d73c:b0a1
  prod: 63.250.54.239 / 2602:ff16:9:0:1:3d9:0:1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:00:47 +04:00
Siavash Sameni
c2be68ca20 docs: comprehensive update all docs to v0.0.46
11 files updated to reflect current state (v0.0.22 → v0.0.46):

ARCHITECTURE.md:
- Ring tones, group calls, read receipts, markdown rendering sections
- Bot API expanded (BotFather, numeric IDs, Telegram compat)
- Admin commands, known issues, 155 tests

TASK_PLAN.md:
- All P1-P4 marked DONE with version numbers
- Additional completed work section (bots, ETH, ring tones, group calls)
- New FC-P7 (Voice & Transport): cpal, Sender Keys, WebTransport
- FC-P6-T9/T10 added

PROGRESS.md:
- Full version history table v0.0.22 through v0.0.46
- Known issues section

README.md:
- Voice calls, ring tones, group calls, read receipts, markdown, 155 tests

SECURITY.md:
- Bot API security, voice call security, admin commands sections
- Updated protection tables

USAGE.md:
- Group calls, read receipts, markdown formatting, admin commands

CLIENT.md:
- Call commands, read receipts, markdown rendering

LLM_HELP.md + LLM_BOT_DEV.md:
- Call/group call/admin commands, ring tones, per-bot numeric IDs

TESTING_E2E.md:
- Tests 16-18: ring tones, group calls, admin commands

CLAUDE.md:
- Ring tone notes, group signal endpoint, MLS roadmap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:47:13 +04:00
Siavash Sameni
d7b75a6641 roadmap: add MLS (RFC 9420) for E2E group call encryption
- FC-P5-T5 added to task plan with full design notes
- OpenMLS/TreeKEM approach: O(log n) key rotation, forward secrecy
- Current group calls marked as transport-encrypted only (QUIC)
- UI warning shown when starting group call
- Updated completed task statuses (read receipts, wrapping, tab complete)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:33:20 +04:00
Siavash Sameni
93923676a8 v0.0.46: fix group calls, admin commands, ETH in members
Server:
- POST /v1/groups/:name/signal — broadcast plaintext JSON to all
  online group members via WS push (for call signals, typing, etc.)
- No auth required (membership validated from 'from' field)

Web client:
- Group call signals now use /signal endpoint (was broken with /send)
- WS handler detects group_call JSON signals early
- Auto-join #ops now properly registers presence + fetches members
- /gmembers resolves ETH addresses via /v1/resolve
- /admin-calls — list all active calls in the system
- /admin-help — list all admin/debug commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:31:00 +04:00
Siavash Sameni
2612d46f5c v0.0.45: call ring tones + group calls
Direct call ringing:
- Incoming: oscillator ring tone (440/480Hz, on/off pattern)
- Outgoing: ringback tone (2s on, 4s off) while waiting for answer
- Browser Notification API for background incoming calls
- All tones stop on answer/reject/hangup
- Notification permission requested on first click

Group calls:
- /gcall — start a group call (notifies all group members)
- /gjoin — join an active group call
- /gleave-call — leave group call
- /gmute — toggle per-group call notification mute (localStorage)
- Participant tracking (joined/left messages with count)
- Group call signal via group message broadcast (JSON type: group_call)
- Call bar shows "Join Call" button when group call is active
- Audio via same WZP bridge (room = gc-<groupname>)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:12:25 +04:00
Siavash Sameni
983afc5916 Merge feature/wzp-call-infrastructure: v0.0.22→v0.0.44
50 commits: call infrastructure, bot API, federation, web/TUI polish.

Key features:
- Voice calls via WZP audio bridge (signaling + audio)
- Telegram-compatible Bot API (BotFather, getUpdates, sendMessage)
- Server-to-server federation (persistent WS, presence sync)
- Web: markdown, call UI, ETH display, inline keyboards
- TUI: markdown, read receipts, call commands, tab completion
- Session versioning, wire envelope format, auto-backup
- 155 tests passing
2026-03-30 08:52:30 +04:00
Siavash Sameni
81954b1b0c v0.0.44: web UI polish — ETH display, peer input, call fixes, docs
Web UI:
- Peer input Enter key now resolves ETH/@alias (like /peer command)
- ETH address stored and shown everywhere instead of raw fingerprint
- Call UI shows ETH address: "Calling 0x0021...", "In call with 0x9D70..."
- Server URL color: #444#666 (readable on dark background)
- Peer input placeholder: "ETH address, fingerprint, or @alias"
- peerEthAddr persisted in localStorage across sessions

Server:
- WS binary header: strip zero-padding from 64-char to 32-char fingerprint
- Call routing now works (was failing due to padded fingerprint lookup)
- startCall() resolves ETH/alias before sending CallSignal::Offer
- Audio bridge sends auth token to wzp-web as first WS message
- Deterministic room name: sorted fingerprint pair (both peers same room)

Docs updated:
- SERVER.md: WZP integration section (components, running, TLS, auth flow)
- USAGE.md: voice call usage for web and TUI
- LLM_HELP.md: call architecture, key files, environment vars
- LLM_BOT_DEV.md: note that bots cannot participate in calls
- TESTING_E2E.md: updated WZP prerequisites with correct flags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:32:31 +04:00
Siavash Sameni
7c4e6a1c1e fix: remove unnecessary parentheses warning in resolve.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:51:21 +04:00
Siavash Sameni
db88282bf6 fix: replace JS lookbehind regex (Safari compat) in markdown renderer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:49:31 +04:00
Siavash Sameni
5bbc197369 docs: comprehensive E2E testing guide (15 test scenarios + quick smoke test)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:34:56 +04:00
Siavash Sameni
87d7ab16c2 v0.0.43: FC-P3-T4 — voice calls via WZP audio bridge
Web client:
- After call goes "active", connects to WZP web bridge WS
- Mic capture: getUserMedia → ScriptProcessor → PCM int16 frames → WS
- Playback: WS → PCM int16 → Float32 → AudioContext.createBufferSource
- Room name derived from peer fingerprint (deterministic)
- Relay address fetched from /v1/wzp/relay-config
- Audio auto-starts on accept/answer, auto-stops on hangup/reject
- startAudio()/stopAudio() manage full lifecycle

TUI:
- /call shows "Audio: use web client for voice (TUI audio coming soon)"
- Signaling works, audio requires web client for now

This completes the last critical task — voice calls work end-to-end:
  User A calls → signaling via featherChat WS → User B accepts →
  both connect to WZP relay → audio flows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:29:44 +04:00
Siavash Sameni
6f1dbde7cc v0.0.42: markdown rendering in TUI messages
- **bold**, *italic*, \`code\` rendered with ratatui styles
- # headers, > blockquotes, - bullet lists
- Multi-line messages split and indented per line
- Code spans: cyan bold, headers: white bold, quotes: gray italic
- No external dependency (custom md_to_spans parser)
- tui-markdown had ratatui version mismatch, built our own

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:00:28 +04:00
Siavash Sameni
5bc59376f5 v0.0.41: FC-P6-T2 — read receipts when messages are visible
- ChatLine gains sender_fp field for tracking who sent each message
- App gains read_receipts_sent HashSet to avoid duplicate receipts
- After each draw(), visible received messages get a Read receipt sent
- Only fires once per message_id, skips system/self messages
- Sender sees blue ✓✓ (existing display logic already handles Read)
- All ChatLine literals across 6 files updated with sender_fp field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:50:47 +04:00
Siavash Sameni
1295f1c937 v0.0.40: reliability — call reload, ETH cache prefill, 10 server tests
Call state reload on restart:
- Loads Ringing/Active calls from sled into active_calls on startup
- Expires calls older than 24h automatically

TUI sender ETH cache prefill:
- prefill_eth_cache() resolves all known contacts on poll_loop start
- First message from known contacts now shows ETH address immediately

Server integration tests (10 new):
- push_to_client offline/online
- register_ws + connection cap (5 max)
- is_online + device_count
- kick_device + revoke_all_except
- deliver_or_queue offline/online
- call state lifecycle
- list_devices

155 tests passing (was 135)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:39:47 +04:00
Siavash Sameni
c37bd7934c v0.0.39: contacts online, message wrap, tab complete, multipart, OTPK
FC-P2-T6: /contacts shows online status (● online, ○ offline)
FC-P6-T6: Long messages word-wrap into multiple lines with aligned indent
FC-P6-T7: Tab completion for 33 slash commands (4 new tests)
FC-P8-T6: sendDocument accepts both JSON and multipart form data
OTPK: Auto-replenish on TUI startup when supply < 3 (generates 10 new)

135 tests passing (was 127)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:22:42 +04:00
Siavash Sameni
5764719375 v0.0.38: FC-P4 complete — session versioning, wire envelope, auto-backup
FC-P4-T1: Session State Versioning
- RatchetState serialize/deserialize with [MAGIC:0xFC][VERSION:1][bincode]
- Legacy (raw bincode) still loads — backward compatible
- Client + WASM both use versioned format
- 2 new tests: roundtrip + legacy compat

FC-P4-T2: WireMessage Versioning Envelope
- Format: [WZ magic][version:u8][length:u32 BE][bincode payload]
- Server + client + WASM accept both envelope and legacy on receive
- Client still sends raw bincode (server handles both)
- Future version → "update required" error instead of crash
- 3 new tests: roundtrip, legacy compat, future version rejection

FC-P4-T3: Periodic Auto-Backup
- Every 5 minutes, encrypts sessions+contacts+sender_keys to ~/.warzone/backups/
- HKDF-derived key from seed, ChaCha20-Poly1305 AEAD
- Atomic writes (temp file + rename), rotates to keep last 3
- /backup command for manual trigger

127 tests passing (was 122)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:03:02 +04:00
Siavash Sameni
a368ab24d2 v0.0.37: TUI call state UI, missed calls, inline keyboards in web
FC-P2-T4: TUI call state machine
- CallInfo struct + CallPhase enum (Calling/Ringing/Active)
- Header shows call indicator: yellow "Calling...", magenta "Incoming", green timer
- /call sets Calling, /accept sets Active, /reject+/hangup clears
- Incoming signals show contextual messages (Offer→prompt, Answer→connected, etc.)

FC-P2-T5: Missed call display in TUI
- WS Text frames parsed for missed_call + bot_message JSON
- Missed calls: "📞 Missed call from X at HH:MM" + terminal bell
- Bot messages rendered as @botname: text

FC-P8-T5: Inline keyboard buttons in web
- CSS styled keyboard buttons (.kbd-btn)
- Bot messages with reply_markup render clickable button rows
- Click sends callback_data back to bot as bot_message
- Works in both WS text handler and handleIncomingMessage fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:44:14 +04:00
Siavash Sameni
3429f518b1 feat: TUI /call, /accept, /reject, /hangup commands (FC-P2-T1+T2+T3)
- /call [fp|@alias|0x...] — send CallSignal::Offer to peer
- /accept — answer incoming call (uses last_dm_peer)
- /reject — reject incoming call
- /hangup — end active call
- All four added to /help text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:19:01 +04:00
Siavash Sameni
e9182fdb41 v0.0.36: web call UI — call/accept/reject/hangup with signaling
Web client:
- Call bar between header and messages (hidden when idle)
- Call button appears when peer is set (not group)
- Incoming call: pulsing notification + Accept/Reject buttons
- Call states: idle → calling → ringing → active
- /call, /accept, /reject, /hangup slash commands
- CallSignal sent via WS binary frames (same as messages)
- handleCallSignal processes Offer/Answer/Hangup/Reject/Ringing/Busy
- Vibration on incoming call (mobile)
- create_call_signal WASM import wired up

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:07:03 +04:00
Siavash Sameni
0b58ddcee5 v0.0.35: WASM create_call_signal, selectable identity, web sections
FC-P3-T2: WASM create_call_signal() export
- Accepts signal_type string (offer/answer/hangup/etc), payload, target
- Returns bincode WireMessage::CallSignal bytes for WS send

FC-P3-T9: Selectable identity display in web
- ETH address shown in code-style block, click to copy
- addSys() gains rawHtml parameter for rich content

FC-P3-T5: Section navigation comments in web.rs
- 5 section markers: State, Crypto, Network, UI, Commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:00:43 +04:00
Siavash Sameni
0e7277fb20 fix: visible scrollbar on web messages area
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:51:43 +04:00
Siavash Sameni
7628ff7a75 v0.0.34: fix bot sendMessage — store per-bot numeric ID reverse mapping
Per-bot numeric IDs (privacy feature) broke sendMessage because the
reverse lookup couldn't find the fingerprint from the per-bot hash.

Fix: store numid:<numeric_id> → fingerprint in tokens tree when
generating updates. resolve_chat_id checks this mapping first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:35:52 +04:00
Siavash Sameni
3489a7cf74 fix: log full bot tokens + write to data_dir/bot-tokens.txt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:05:11 +04:00
Siavash Sameni
1e47b888c8 v0.0.33: bump version
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:51:39 +04:00
Siavash Sameni
5415d1f5c8 fix: auto-join #ops creates group if missing, remove auth from create/join group
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:47:54 +04:00
Siavash Sameni
13f2227bf0 v0.0.32: system bots config — persist across data wipes, welcome screen
Server:
- --bots-config <path> loads JSON array of system bots on startup
- Bots auto-created if missing, aliases restored on every start
- Bot list stored in DB for welcome screen (system:bot_list key)
- GET /v1/bot/list returns system bots (public, no auth)

Welcome screen:
- Web + TUI show available bots on first login
- "Available bots: @helpbot — featherChat help, @codebot — Coding..."
- Clickable in web (via address detection)

Config: bots.example.json with 10 suggested bots
Usage: warzone-server --enable-bots --bots-config bots.json

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:07:34 +04:00
Siavash Sameni
f04c24187d feat: auto-join #ops on first login (web + TUI)
New users with no peer/group set automatically join #ops so they
have someone to talk to. Saved peer overrides this on subsequent visits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:54:19 +04:00
Siavash Sameni
3e583bb04b v0.0.31: per-bot unique user IDs, remove raw fingerprint from bot API
Privacy: from.id is now Hash(bot_token + user_fp) → different bots see
different numeric IDs for the same user. Prevents cross-bot user correlation.

Removed id_str (raw hex fingerprint) from all bot API responses.
Updated LLM_BOT_DEV.md and LLM_HELP.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:49:10 +04:00
Siavash Sameni
6fee73fc4d v0.0.30: markdown rendering in web, fix scrolling
Web:
- Markdown renderer: **bold**, *italic*, `code`, ```code blocks```,
  # headers, [links](url), > blockquotes, - lists
- All message text rendered as markdown (bot messages look great now)
- Fixed scroll: overflow-y: scroll + min-height: 0 on messages container
- CSS for code blocks, pre, headers, blockquotes, lists
- Styled: code=cyan bg, pre=dark bg+border, bold=white, italic=amber

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:36:00 +04:00
Siavash Sameni
8b37bd4323 fix: getUpdates enforces min 1s delay when empty (prevents tight-loop spam)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:25:41 +04:00
Siavash Sameni
b0fa9f92bd fix: BotFather stores rec: AliasRecord so resolve_alias finds bot aliases
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:21:45 +04:00
Siavash Sameni
4118be7ef3 docs: update LLM bot dev guide with BotFather chat flow + plaintext auto-detect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:27:13 +04:00
Siavash Sameni
76fd8dd81a fix: web bot detection checks alias name first, then whois fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:19:34 +04:00
Siavash Sameni
e0e747e005 fix: BotFather fingerprint uses all-hex (00000000000000000b0ffa00e000000f)
Old fp contained non-hex chars (o,r) which got stripped by normFP,
causing whois lookup failure and bot detection to miss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:17:05 +04:00
Siavash Sameni
76ee2ab585 fix: BotFather alias record ensures resolve_alias works after data wipe
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:14:41 +04:00
Siavash Sameni
878847ce89 fix: recognize @botfather as bot peer (special case, not pattern change)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:11:28 +04:00
Siavash Sameni
362e7a765b v0.0.29: BotFather — create bots by messaging @botfather
Built-in BotFather (Rust, server-side):
- Intercepts messages to @botfather in deliver_or_queue
- Commands: /newbot <name>, /mybots, /deletebot <name>, /token <name>
- Creates bot with fingerprint, token, alias, tracks ownership
- Replies via push_to_client or queue (works offline)
- Only active when --enable-bots is set

Standalone BotFather (Python):
- tools/botfather.py: uses bot API (getUpdates/sendMessage)
- Delegates core ops to built-in handler
- Extensible for additional features
- Reads token from BOTFATHER_TOKEN env or .botfather_token file

Flow: User messages @botfather → "/newbot MyBot" → gets token back

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:08:35 +04:00
Siavash Sameni
9dd7341809 fix: build-bleeding uses fedora-43
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:06:10 +04:00
Siavash Sameni
6196057f3e feat: build-bleeding.sh — Arch Linux Docker on Fedora VM for bleeding edge builds
VM: fc-bleeding (Fedora 41), Build: archlinux:latest Docker container
Output: target/linux-x86_64-bleeding/
No conflict with fc-builder (build-linux.sh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:04:31 +04:00
Siavash Sameni
76cac77259 v0.0.28: BotFather-only registration, per-instance bot toggle, docs update
Security:
- Bot registration restricted to BotFather (requires botfather_token)
- Direct POST /v1/bot/register without BotFather auth → rejected

Deploy:
- systemd service reads /home/warzone/server.env for EXTRA_ARGS
- deploy/warzone-server.env.mequ: no bots (default)
- deploy/warzone-server.env.kh3rad3ree: --enable-bots
- setup.sh copies per-hostname env file

Docs updated:
- LLM_HELP.md: BotFather flow, plaintext bot messaging, E2E option, bridge
- LLM_BOT_DEV.md: botfather_token requirement, E2E mode, bridge section
- BOT_API.md: full BotFather flow, ownership, numeric IDs, webhook delivery
- SERVER.md: --enable-bots flag, per-instance config, bot system section
- USAGE.md: bot messaging, BotFather, bridge tool

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:52:12 +04:00
Siavash Sameni
8603087afb v0.0.27: TG-compatible bots — plaintext send, numeric IDs, webhooks, BotFather
Bot compatibility:
- Clients send plaintext bot_message to bot aliases (no E2E encryption)
- Numeric chat_id: fp_to_numeric_id() deterministic hash, accept string/number
- Webhook delivery: POST updates to bot's webhook URL (async, fire-and-forget)
- getUpdates timeout raised to 50s (was 30, TG uses 50)
- parse_mode HTML rendered in web client
- E2E bot registration: optional seed + bundle for encrypted bot sessions

BotFather + instance control:
- --enable-bots CLI flag (default: disabled)
- BotFather auto-created on first start (@botfather alias)
- Bot ownership: owner fingerprint stored in bot_info
- All bot endpoints return 403 when disabled

Bot Bridge:
- tools/bot-bridge.py: TG-compatible proxy for unmodified TG bots
- Translates chat_id int↔string, proxies getUpdates/sendMessage
- README with python-telegram-bot and Telegraf examples

Test fixes:
- Updated tests for ETH address display in header/messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:45:45 +04:00
Siavash Sameni
067f1ea20b v0.0.25: fix text selection in web chat, don't steal focus when selecting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:15:40 +04:00
Siavash Sameni
b9e7b3e05c fix: arch linux uses rustup for wasm target support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:12:09 +04:00
Siavash Sameni
deb220ff2c fix: SW uses network-first strategy, updates apply without clearing storage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:11:15 +04:00
Siavash Sameni
0697c988fa fix: build-linux.sh --local cd to project root before building
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:06:29 +04:00
Siavash Sameni
1851728a09 v0.0.24: ETH display in TUI header/messages, web peer resolve, click-focus
TUI:
- Header shows peer ETH address (resolved on /peer set)
- Own messages show ETH format
- Resolve display shows full formatted fingerprint (xxxx:xxxx:...)
- peer_eth field stored on App for header display

Web:
- Pasting 0x address in peer input box now resolves via /v1/resolve/
- Send path resolves 0x/@ before encrypting
- Click messages area → focuses text input
- Own messages show ETH format

Version: 0.0.23 → 0.0.24, SW cache wz-v4 → wz-v5
Build script: --local, --local-ship, --local-clean commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:04:37 +04:00
Siavash Sameni
ea04405199 v0.0.23: ETH display everywhere, local build, web UX fixes
Version: 0.0.22 → 0.0.23, SW cache wz-v3 → wz-v4

TUI:
- Own messages show ETH address (0x...) instead of fingerprint
- Received messages: async ETH cache lookup (resolve on first sight)
- /info shows Identity + Fingerprint
- Welcome message shows ETH address

Web:
- Header shows only ETH address (single element, click to copy)
- Own messages show ETH format
- Received messages resolve sender ETH via /v1/resolve/
- /peer 0x... resolves via /v1/resolve/ endpoint
- Click messages area → focuses text input

Client:
- register_bundle sends eth_address to server
- ETH↔fingerprint mapping stored on registration

Build:
- --local: build on current machine (auto-detect apt/dnf/pacman/brew)
- --local-ship: build locally + deploy to all servers
- --local-clean: build + clean cargo cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 08:50:31 +04:00
Siavash Sameni
2aa58a4319 fix: TUI shows ETH address, /peer 0x... resolves, Cmd+key on macOS
TUI header: shows ETH address (0x...) instead of fingerprint
/peer 0x...: resolves via GET /v1/resolve/:address endpoint
Cmd+A/E/U/K/W: macOS SUPER modifier now handled alongside CONTROL
Added resolve_address() method for ETH/any address resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 08:20:38 +04:00
Siavash Sameni
3efce2ddf4 v0.0.22: version bump, ETH identity in web client, version bump rule
Version:
- Workspace + protocol: 0.0.21 → 0.0.22
- Web client VERSION: 0.0.17 → 0.0.22
- Service worker cache: wz-v2 → wz-v3

ETH identity:
- Added WasmIdentity::eth_address() export (derives from seed via secp256k1)
- Web client sends eth_address during key registration
- Identity display shows ETH address first, then fingerprint
- No more server-side resolve needed — computed client-side

CLAUDE.md:
- Added MANDATORY version bump rule (4 places to update)
- Must bump on every functional change, never skip SW cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 08:11:31 +04:00
Siavash Sameni
fcbf2d5859 feat: complete Telegram-compatible Bot API + bot dev guide
Bot API (routes/bot.rs — full rewrite):
- getUpdates: persistent update_id counter, offset acknowledgement,
  limit (max 100), long-poll up to 30s with 1s intervals
- sendMessage: parse_mode, reply_to_message_id, reply_markup (inline keyboards)
- answerCallbackQuery: acknowledge button clicks
- editMessageText: update sent messages
- setWebhook / deleteWebhook / getWebhookInfo: webhook configuration
- sendDocument: file reference with caption
- Bot queue: raw messages migrated to bot_queue:<fp>:<update_id> for ordering

Web client (routes/web.rs):
- Bot messages rendered properly (was showing "[message could not be decrypted]")
- Handles bot_message, bot_edit, bot_document as both Text and Binary WS frames
- Inline keyboard buttons rendered as bracketed text
- Missed call notifications handled in Text frame path

Docs:
- LLM_BOT_DEV.md: token-optimized bot dev reference for coding assistant LLM
  (Python + Node.js examples, all endpoints, TG compatibility table)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 07:50:14 +04:00
Siavash Sameni
953b3bd13a docs: CLAUDE.md design principles, update ARCHITECTURE + SECURITY
- CLAUDE.md: design principles (E2E by default, semi-trusted server,
  federation transparency, TG bot compat), coding conventions for Rust/TUI/
  WASM/federation/bots, task naming, key files reference
- ARCHITECTURE.md: added bots to high-level diagram, friends/bot/resolve
  modules, 9 sled trees (was 7), bot API sequence diagram, addressing table,
  federated features table, test count 72→122
- SECURITY.md: v0.0.21, added friend list/API auth/device/bot alias to
  protected assets, auth & authorization section, rate limiting, session recovery

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 07:39:30 +04:00
Siavash Sameni
210fbbb35b feat: bot alias reservation + BOT_API.md documentation
- Aliases ending with Bot/bot/_bot reserved for registered bots only
- Non-bot users get clear error directing to /v1/bot/register
- Bot registration auto-creates alias (@name_bot suffix)
- BOT_API.md: full developer guide with endpoints, examples, echo bot
- LLM_HELP.md: expanded bot section with update types + Python example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 07:34:45 +04:00
Siavash Sameni
7b72f7cba5 feat: friend list, bot API, ETH addressing, deep links, docs overhaul
Tier 1 — New features:
- E2E encrypted friend list: server stores opaque blob (POST/GET /v1/friends),
  protocol-level encrypt/decrypt with HKDF-derived key, 4 tests
- Telegram Bot API compatibility: /bot/register, /bot/:token/getUpdates,
  sendMessage, getMe — TG-style Update objects with proper message mapping
- ETH address resolution: GET /v1/resolve/:address (0x.../alias/@.../fp),
  bidirectional ETH↔fp mapping stored on key registration
- Seed recovery: /seed command in TUI + web client
- URL deep links: /message/@alias, /message/0xABC, /group/#ops
- Group members with online status in GET /groups/:name/members

Tier 2 — UX polish:
- TUI: /friend, /friend <addr>, /unfriend <addr> with presence checking
- Web: friend commands, showGroupMembers() on group join
- Web: ETH address in header, clickable addresses (click→peer or copy)
- Bot: full WireMessage→TG Update mapping (encrypted base64, CallSignal,
  FileHeader, bot_message JSON)

Documentation:
- USAGE.md rewritten: complete user guide with all commands
- SERVER.md rewritten: full admin guide with all 50+ endpoints
- CLIENT.md rewritten: architecture, commands, keyboard, storage
- LLM_HELP.md created: 1083-word token-optimized reference for helper LLM

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 07:31:54 +04:00
Siavash Sameni
dbf5d136cf fix: WASM double-X3DH bug, federated aliases, deploy tooling
WASM fix (critical):
- encrypt_key_exchange_with_id was calling x3dh::initiate a second time,
  generating a new ephemeral key that didn't match the ratchet — receiver
  always failed to decrypt. Now stores X3DH result from initiate() and
  reuses it. Added 2 protocol tests confirming the fix + the bug.
- Bumped service worker cache to wz-v2 to force browsers to re-fetch.
- Disabled wasm-opt for Hetzner builds (libc compat issue).

Federation — alias support:
- resolve_alias falls back to federation peer if not found locally
- register_alias checks peer server before allowing — globally unique aliases
- Added resolve_remote_alias() and is_alias_taken_remote() to FederationHandle

Federation — key proxy fix:
- Remote bundles no longer cached locally (stale cache caused decrypt failures)
- Local vs remote determined by device: prefix in keys DB

Client fixes:
- Self-messaging blocked ("Cannot send messages to yourself")
- /peer <self> blocked
- last_dm_peer never set to self
- /r <message> sends reply inline (switches peer + sends in one command)

Deploy tooling:
- scripts/build-linux.sh with --ship (build + deploy + destroy)
- --update-all, --status, --logs commands
- WASM rebuilt on Hetzner VM before server binary
- deploy/ directory: systemd service, federation configs, setup script
- Journald log cap (50MB, 7-day retention)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:59:19 +04:00
Siavash Sameni
f8eaf30bb4 refactor: federation uses persistent WS instead of HTTP polling
- Server-to-server communication via WebSocket at /v1/federation/ws
- Auth as first WS frame (shared secret), presence + forwards over same connection
- Auto-reconnect every 3s on disconnect, instant presence push on connect
- Replaces HTTP REST polling (no more 5s intervals, lower latency)
- Removed dead HMAC helpers (auth is now direct secret comparison over WS)
- Simplified ARCHITECTURE.md mermaid diagrams for Gitea rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:56:13 +04:00
Siavash Sameni
3e0889e5dc v0.0.21: TUI overhaul, WZP call infrastructure, security hardening, federation
TUI:
- Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod)
- Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge
- /help command, terminal bell on incoming DM, /devices + /kick commands
- 44 unit tests (types, input, draw with TestBackend)

Server — WZP Call Infrastructure (FC-2/3/5/6/7/10):
- Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees)
- WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline)
- Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out)
- Presence API (GET /presence/:fp, POST /presence/batch)
- Missed call flush on WS reconnect
- WZP relay config + CORS

Server — Security (FC-P1):
- Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers)
- Session auto-recovery (delete corrupted ratchet, show [session reset])
- WS connection cap (5/fingerprint) + global concurrency limit (200)
- Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all)

Server — Federation:
- Two-server federation via JSON config (--federation flag)
- Periodic presence sync (every 5s, full-state, self-healing)
- Message forwarding via HTTP POST with SHA-256(secret||body) auth
- Graceful degradation (peer down = queue locally)
- deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs

Client — Group Messaging:
- SenderKeyDistribution storage + GroupSenderKey decryption in TUI
- sender_keys sled tree in LocalDb

WASM:
- All 8 WireMessage variants handled (no more "unsupported")
- decrypt_group_message() + create_sender_key_from_distribution() exports
- CallSignal parsing with signal_type mapping

Docs:
- ARCHITECTURE.md rewritten with Mermaid diagrams
- README.md created
- TASK_PLAN.md with FC-P{phase}-T{task} naming
- PROGRESS.md updated to v0.0.21

WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:45:58 +04:00
Siavash Sameni
4a4fa9fab4 v0.0.21: FC-CRATE-1 — make warzone-protocol importable standalone
Replaced workspace dep inheritance with explicit versions in
warzone-protocol/Cargo.toml. The crate now builds both as a
workspace member AND standalone.

WZP can now import warzone-protocol directly:
  warzone-protocol = { path = "../featherChat/warzone/crates/warzone-protocol" }

This means WZP can delete its mirrored identity/crypto code and use:
- warzone_protocol::identity::{Seed, IdentityKeyPair, PublicIdentity}
- warzone_protocol::crypto::{hkdf_derive, aead_encrypt, aead_decrypt}
- warzone_protocol::ethereum::{derive_eth_identity, EthAddress}
- warzone_protocol::message::{WireMessage, CallSignalType}
- warzone_protocol::types::Fingerprint

Single source of truth for identity derivation — no more HKDF mismatches.

28/28 tests pass. Zero warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:21:18 +04:00
Siavash Sameni
064a730b42 v0.0.21: WZP integration groundwork — CallSignal + token validation
WZP-FC-1: CallSignal WireMessage variant
- CallSignalType enum: Offer, Answer, IceCandidate, Hangup, Reject, Ringing, Busy
- Routed through existing E2E encrypted channels
- Server dedup handles new variant
- TUI shows "📞 Call signal: Offer" etc
- CLI recv prints call signals

WZP-FC-4: Token validation endpoint
- POST /v1/auth/validate { "token": "..." }
- Returns: { "valid": true, "fingerprint": "...", "alias": "..." }
- WZP relay calls this to verify featherChat bearer tokens
- Resolves alias alongside fingerprint

These two unblock WZP integration tasks WZP-S-2 (accept FC tokens)
and WZP-S-3 (signaling bridge mode).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:13:23 +04:00
Siavash Sameni
65f639052e Append WZP integration tasks to FUTURE_TASKS.md (238→676 lines)
featherChat side (10 tasks):
  WZP-FC-1: CallSignal WireMessage variant (2-4h)
  WZP-FC-2: Call state management + sled tree (1-2d)
  WZP-FC-3: WS handler for call signaling (0.5d)
  WZP-FC-4: Auth token validation endpoint (2-4h)
  WZP-FC-5: Group-to-room mapping (1d)
  WZP-FC-6: Presence/online status API (0.5-2d)
  WZP-FC-7: Missed call notifications (0.5d)
  WZP-FC-8: Cross-project identity verification test (2-4h) CRITICAL
  WZP-FC-9: HKDF salt investigation — VERIFIED: no mismatch (b""→None == None)
  WZP-FC-10: WZP web bridge shared auth (1-2d)

WZP side suggestions (9 items):
  WZP-S-1 through WZP-S-9 covering auth, signaling bridge,
  room access control, proto publishing, CLI flags, and
  6 hardcoded assumptions that conflict with integration.

All tasks reference specific file:line in both codebases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:50:13 +04:00
Siavash Sameni
619af027dc Update warzone-phone submodule to ac3b997
WZP aligned HKDF info strings with featherChat:
- "warzone-ed25519-identity" → "warzone-ed25519"
- "warzone-x25519-identity" → "warzone-x25519"

Same seed now produces identical keys in both projects.
Shared identity prerequisite is met.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:27:49 +04:00
Siavash Sameni
007ca7521d FUTURE_TASKS.md: 18 optional tasks with questions-before-starting
High priority:
  1. Auth enforcement middleware
  2. Session auto-recovery
  3. Crypto audit plan

Medium priority:
  4. Extract web client from monolith
  5. Session state versioning
  6. Periodic auto-backup
  7. WireMessage versioning

Normal priority:
  8. Mule binary implementation
  9. libsignal migration assessment
  10. OIDC identity provider
  11. Smart contract ACL
  12. DNS federation
  13. WarzonePhone integration

Low priority:
  14. Message search
  15. Read receipts
  16. Typing indicators
  17. Message reactions
  18. Voice messages

Each task includes: what, why, effort estimate, and blocking
questions that must be answered before work begins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:21:14 +04:00
Siavash Sameni
de1ce77fea IDP_SMART_CONTRACT.md: featherChat as IdP + on-chain ACL (1111 lines)
featherChat as Identity Provider:
- OIDC provider endpoints (/auth/oidc/authorize, /token, /userinfo)
- JWT tokens with fingerprint, alias, eth_address, groups claims
- Authentik integration (featherChat as upstream IdP, group sync)
- SAML support for enterprise

Smart Contract Access Control:
- FeatherChatACL Solidity contract (server/group/feature access)
- secp256k1 address from same BIP39 seed = on-chain identity
- NFT-gated access (ERC-721/ERC-1155 membership)
- Token-gated access (ERC-20 staking)
- DAO governance for group membership decisions
- UUPS upgradeable proxy pattern

Hybrid architecture:
- OIDC token carries on-chain permissions as claims
- Event-driven sync (WebSocket RPC + periodic poll + sled cache)
- L2 deployment (Arbitrum/Base/Polygon) for low gas costs

Feasibility: 7-11 weeks across 4 phases.
Comparison with SpruceID, Ceramic, Lens, XMTP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:07:34 +04:00
Siavash Sameni
1c7b39c395 Rewrite WZP_INTEGRATION.md with confirmed code references (1209 lines)
All [SPECULATIVE] markers replaced with [CONFIRMED] from actual WZP code.

Key discoveries:
- HKDF info string mismatch: featherChat uses "warzone-ed25519",
  WZP uses "warzone-ed25519-identity" — same seed, different keys.
  Requires 2-line fix in wzp-crypto/src/handshake.rs before integration.
- Media is NOT DTLS-SRTP: WZP uses ephemeral X25519 DH + ChaCha20-Poly1305
  with deterministic nonces (WireGuard-like, not WebRTC-like)
- Transport is QUIC (quinn), not WebRTC/ICE
- FEC is RaptorQ fountain codes, not Opus inband
- 5 codecs: Opus 24k → Codec2 1200bps with adaptive switching
- Relay operates on encrypted packets (zero-knowledge relay)

18 sections with concrete API contracts, code file:line references,
and phased implementation roadmap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:02:30 +04:00
Siavash Sameni
95e7e0b1a9 Add WarzonePhone as git submodule at warzone-phone/
ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 07:54:14 +04:00
Siavash Sameni
f7a517d8ea WZP_INTEGRATION.md: featherChat ↔ WarzonePhone integration spec (1001 lines)
Covers: shared identity model (same BIP39 seed), authentication flow
(Ed25519 signed tokens), call signaling via WireMessage::CallSignal,
DTLS-SRTP media encryption bootstrapped from Double Ratchet,
group calls (SFU + Sender Keys), warzone scenarios (voice messages
as attachments, mule delivery for missed calls).

Phased roadmap: shared identity → signaling → encrypted calls → group calls.

featherChat-side details confirmed against code.
WZP-side details marked [SPECULATIVE] (WZP codebase was inaccessible).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 05:38:45 +04:00
Siavash Sameni
2dbbc61dfe Comprehensive documentation: architecture, usage, integration, progress, security
docs/ARCHITECTURE.md (531 lines):
  System design, ASCII diagrams, crypto stack, dual-curve identity,
  wire protocol (7 WireMessage variants), server/client architecture,
  data flow diagrams, storage model, extensibility points

docs/USAGE.md (550 lines):
  Complete user guide: installation, all CLI commands (10),
  all TUI commands (20+), all web commands, file transfer,
  identity management, aliases, groups, multi-device, backup,
  keyboard shortcuts

docs/INTEGRATION.md (542 lines):
  WarzonePhone concept, Ethereum/Web3, OIDC, DNS federation,
  transport abstraction, multi-server mode, custom clients,
  ntfy, how-to guides for extending message types/commands/storage

docs/PROGRESS.md (234 lines):
  Timeline, Phase 1 (16 features), Phase 2 (16 features),
  v0.0.20, 28 tests, bugs fixed, known limitations, Phase 3-7 roadmap

docs/SECURITY.md (438 lines):
  Threat model, 8 crypto primitives, key derivation paths,
  forward secrecy, Sender Keys trade-offs, seed security,
  server trust, WASM security, known weaknesses,
  comparison with Signal/Matrix/SimpleX

Total: 3,751 lines across 8 doc files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 05:25:46 +04:00
Siavash Sameni
fb987da8ac v0.0.20: file transfer in groups
/file <path> now works in group mode (#group):
- Sends file header + chunks to each group member
- Same fan-out approach as group text messages
- Each member receives and reassembles independently
- Progress shown: "Sending 'file.pdf' to group #ops..."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:23:19 +04:00
Siavash Sameni
1601decf33 v0.0.19: contact list + message history (local, persistent)
Storage:
- contacts sled tree: auto-tracked on send/receive
  - fingerprint, alias, first_seen, last_seen, message_count
- history sled tree: all messages stored locally
  - key: hist:<peer_fp>:<timestamp>:<uuid> for ordered scan
  - sender, text, is_self, timestamp

TUI commands:
- /contacts or /c — list all contacts (sorted by most recent)
  Shows alias, fingerprint, message count
- /history or /h — show last 50 messages with current peer
- /h <fingerprint> — show history with specific peer

Auto-tracking:
- On send: touch_contact + store_message (is_self=true)
- On receive: touch_contact + store_message (is_self=false)
- Both KeyExchange and Message variants tracked

Backup: contacts + history included in export_all (encrypted backup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:16:22 +04:00
Siavash Sameni
741e6fbcfd v0.0.18: proper line editing in TUI input
Keyboard shortcuts:
- Left/Right: move cursor
- Home / Ctrl+A: beginning of line
- End / Ctrl+E: end of line
- Alt+Left/Right: word jump
- Alt+Backspace: delete word back
- Ctrl+W: delete word back
- Ctrl+U: clear entire line
- Ctrl+K: kill to end of line
- Delete: delete char at cursor
- Backspace: delete char before cursor

Cursor position tracked, chars insert at cursor (not just append).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:04:12 +04:00
Siavash Sameni
a4405b4976 v0.0.17: fix /r reply in TUI, /p shortcut, /eth, /unalias
TUI fixes:
- /r and /reply now work: tracks last_dm_peer from received messages
- /r switches peer to last DM sender, then type normally
- /p @alias works as shortcut for /peer @alias
- /eth shows Ethereum address in TUI
- /unalias removes your alias

Web fixes:
- /p @alias and /peer @alias resolve and set peer
- /r and /reply work (switch to last DM sender)
- /unalias removes alias
- /admin-unalias <alias> <password> for admin removal
- File download now shows as clickable link (not auto-download)

Server:
- POST /v1/alias/unregister — remove own alias
- POST /v1/alias/admin-remove — admin removes any alias
- WARZONE_ADMIN_PASSWORD env var (default: "admin")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:50:00 +04:00
Siavash Sameni
f4eac7b2aa v0.0.16: clickable file download link instead of auto-download
Files now appear as a styled clickable link in chat:
📎 filename.pdf (1.6 KB) from sender
Click to download. No auto-save dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:17:33 +04:00
Siavash Sameni
ebaf5df671 Web file transfer: send + receive with auto-download
Web client:
- Paperclip file upload button in chat bar
- Chunked upload: 64KB chunks, SHA-256 integrity
- Progress display during send/receive
- Auto-download on complete (browser save dialog)
- Max 10MB per file

WASM:
- decrypt_wire_message now returns file_header and file_chunk
  with type, id, filename, chunk data (hex encoded)

Receive flow:
- FileHeader: registers pending transfer
- FileChunk: stores chunk, shows progress
- All chunks received: assembles, triggers blob download

Send flow (web→web or web→CLI):
- File sent as JSON messages (not bincode, for simplicity)
- Receiver handles both JSON and bincode formats

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:07:17 +04:00
Siavash Sameni
c9f3e338a7 Add /p as alias for /peer (both TUI and web), web /p @alias support
TUI: /p @manwe works same as /peer @manwe
Web: /p @manwe and /peer @manwe resolve alias and set peer input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:38:35 +04:00
Siavash Sameni
9c70e02eba v0.0.15: unalias, admin alias removal, /reply, web version fix
Aliases:
- /unalias — remove your own alias
- /admin-unalias <alias> <password> — admin removes any alias
- Admin password via WARZONE_ADMIN_PASSWORD env var (default: "admin")
- POST /v1/alias/unregister + POST /v1/alias/admin-remove

Reply:
- /r or /reply — switches peer to whoever last DM'd you
- lastDmPeer tracked on both web and TUI
- Then type normally to reply

Web:
- Version bumped to 0.0.15 (was stuck at 0.0.10)
- WASM rebuilt with latest protocol

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:12:33 +04:00
Siavash Sameni
608a160614 Fix warnings: remove unused import and variable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:39:05 +04:00
Siavash Sameni
661de47552 v0.0.14: Ethereum-compatible identity (secp256k1 + Keccak-256)
Protocol (ethereum.rs):
- derive_eth_identity(): HKDF from seed (info="warzone-secp256k1")
- secp256k1 signing key (k256 crate)
- Ethereum address: Keccak-256(uncompressed_pubkey[1..])[-20:]
- EIP-55 checksum address formatting
- eth_sign() / eth_verify() for secp256k1 ECDSA
- EthAddress type with Display, hex parsing, checksum
- 5 tests: deterministic, format, checksum, sign/verify, uniqueness

CLI:
- `warzone eth` — show Ethereum address alongside Warzone fingerprint
- Same seed produces both identities (dual-curve)

Dual identity model:
- Ed25519 + X25519 for Warzone messaging (fast, small signatures)
- secp256k1 for Ethereum compatibility (MetaMask, ENS, Ledger/Trezor)
- Both derived from the same BIP39 seed via different HKDF paths

28/28 protocol tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:30:25 +04:00
Siavash Sameni
86da52acc4 v0.0.13: Sender Keys for efficient group encryption
Protocol (sender_keys.rs):
- SenderKey: symmetric key with chain ratchet (forward secrecy per chain)
- generate(), rotate(), encrypt(), decrypt()
- SenderKeyDistribution: share key via 1:1 encrypted channel
- SenderKeyMessage: encrypted group message (O(1) instead of O(N))
- Chain key ratchets forward on each message (HKDF)
- Generation counter for key rotation tracking
- 4 tests: basic, multi-message, rotation, old-key rejection

WireMessage:
- GroupSenderKey variant: encrypted group message
- SenderKeyDistribution variant: key sharing

Server: dedup handles new variants.
CLI TUI + recv: stub handlers for new message types.
23/23 protocol tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:23:10 +04:00
Siavash Sameni
653c6c050b v0.0.12: Encrypted backup/restore + history module
Protocol:
- history.rs: derive_history_key (HKDF from seed, info="warzone-history")
- encrypt_history / decrypt_history (ChaCha20-Poly1305, WZH1 magic)
- 2 new tests (roundtrip + wrong seed), total 19/19

CLI:
- `warzone backup [output.wzb]` — exports all sessions + pre-keys
  as encrypted blob (only your seed can decrypt)
- `warzone restore <input.wzb>` — imports backup, merges (no overwrite)
- Backup format: WZH1 magic + nonce + encrypted JSON

Storage:
- export_all() — dumps sessions + pre-keys as base64 JSON
- import_all() — merges backup data (skip existing entries)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:59:54 +04:00
Siavash Sameni
fff443bb6d v0.0.11: Multi-device support (server-side)
Server:
- Register stores per-device bundles: device:<fp>:<device_id>
- GET /v1/keys/:fp/devices lists all registered devices
- WS already pushes to ALL connected devices per fingerprint
- DB queue: first device to poll gets messages (acceptable for Phase 2)

Multi-device flow:
- Same seed on two devices → same fingerprint
- Both register with different device_ids
- Both connect via WS → both receive messages in real-time
- Each device maintains its own ratchet sessions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:52:22 +04:00
Siavash Sameni
9811248b7c v0.0.10: Progressive Web App (PWA)
- Web manifest (standalone mode, theme, icon)
- Service worker: caches shell (HTML, WASM, icon) for offline
- SVG app icon (chat bubble with encryption indicator)
- iOS meta tags: apple-mobile-web-app-capable, status bar style
- Android: beforeinstallprompt → /install command
- Offline fallback: loads cached shell, shows reconnecting state
- Cache versioning with automatic old cache cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:32:59 +04:00
Siavash Sameni
4fb3973403 v0.0.9: Group management — leave, kick, members
Server:
- POST /groups/:name/leave — remove self from group
- POST /groups/:name/kick — creator can kick members
- GET /groups/:name/members — list with aliases + creator badge

CLI TUI:
- /gleave — leave current group
- /gkick <fp_or_alias> — kick (creator only)
- /gmembers — show member list with aliases and ★ for creator

Web client:
- Same commands: /gleave, /gkick, /gmembers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:04:28 +04:00
Siavash Sameni
2599ce956a v0.0.8: Server-side message deduplication
Server:
- DedupTracker in AppState: bounded HashSet (10,000 IDs, FIFO eviction)
- send_message: extracts message ID from bincode, drops duplicates
- WS handler: dedup on both binary and JSON message frames
- extract_message_id() parses all WireMessage variants

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:00:58 +04:00
Siavash Sameni
708080f7be v0.0.7: Chunked encrypted file transfer
Protocol:
- WireMessage::FileHeader { id, sender_fp, filename, file_size, total_chunks, sha256 }
- WireMessage::FileChunk { id, sender_fp, filename, chunk_index, total_chunks, data }
- 64KB chunks, SHA-256 integrity verification

CLI TUI:
- /file <path> command: reads file, chunks, encrypts each with ratchet, sends
- Progress display: "Sending file.pdf [3/10]..."
- Incoming file reassembly with chunk tracking
- SHA-256 verification on complete
- Saves to data_dir/downloads/
- Max file size: 10MB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:26:05 +04:00
Siavash Sameni
b168ecc609 Add PWA and mark delivery receipts done in Phase 2 roadmap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:16:48 +04:00
Siavash Sameni
104ba78b85 v0.0.6: Delivery receipts (sent/delivered/read)
Protocol:
- WireMessage::Receipt { sender_fingerprint, message_id, receipt_type }
- ReceiptType enum: Delivered, Read
- id field added to KeyExchange and Message variants
- Receipts are plaintext (not encrypted) — contain only ID + type

Web client:
- Auto-sends Delivered receipt on successful decrypt
- Tracks sent message IDs with receipt status
- Displays: ✓ (sent, gray), ✓✓ (delivered, white), ✓✓ (read, blue)
- Receipt indicators update live via DOM reference

CLI TUI:
- Auto-sends Delivered receipt back to sender on decrypt
- Tracks receipt status per message ID
- Displays receipt indicators after sent messages

WASM:
- create_receipt() function for web client
- encrypt_with_id/encrypt_key_exchange_with_id for tracking
- decrypt_wire_message handles Receipt variant

17/17 protocol tests pass. Zero warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:12:43 +04:00
Siavash Sameni
8fad8d8374 Add encrypted message history + cloud backup to Phase 2 roadmap
- History encrypted with key derived from seed (HKDF)
- No extra password needed (seed = access)
- Optional double encryption with passphrase
- Cloud targets: S3, Google Drive, WebDAV
- Backup is encrypted archive, provider sees only blobs
- Incremental sync, versioned, deduplicated
- Also marked WebSocket, TUI, Web WASM as done in Phase 2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:58:57 +04:00
Siavash Sameni
5b21a0e58b Fix group messages: push via WebSocket, not just DB queue
Group send_to_group was writing directly to sled DB, bypassing
the WS push. Connected clients never received group messages.

Now tries push_to_client() first (instant WS delivery),
falls back to DB queue if recipient is offline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:55:08 +04:00
Siavash Sameni
fe2b7d8e8a TUI client: WebSocket with HTTP fallback
poll_loop now:
1. Tries WebSocket connection to /v1/ws/<fingerprint>
2. On success: receives messages in real-time (instant push)
3. On disconnect: reconnects after 3 seconds
4. On WS failure: falls back to HTTP polling every 2 seconds

Refactored message processing into shared functions:
- process_incoming() handles raw bytes
- process_wire_message() handles deserialized WireMessage
- Used by both WS and HTTP paths

Both CLI TUI and web client now use WebSocket:
- No more HTTP polling spam in server logs
- Messages arrive instantly on both clients
- HTTP poll kept as fallback for scripts/mules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:49:46 +04:00
Siavash Sameni
c8a95e27e4 Fix 3 warnings: unused import, unused variable, dead code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:43:50 +04:00
Siavash Sameni
2ca25fd2bf v0.0.5: WebSocket real-time messaging
Server:
- WS endpoint: /v1/ws/:fingerprint
- Connection registry in AppState (fingerprint → WS senders)
- On connect: flushes queued DB messages, then pushes in real-time
- send_message: pushes to WS if connected, falls back to DB queue
- Auto-cleanup on disconnect
- WS accepts both binary and JSON text frames for sending

Web client:
- Replaces 2-second HTTP polling with persistent WebSocket
- Auto-reconnects on disconnect (3-second backoff)
- Sends via WS when connected, HTTP fallback
- Messages arrive instantly (no polling delay)
- "Real-time connection established" shown on connect

HTTP polling still works:
- CLI recv command uses HTTP (unchanged)
- Web falls back to HTTP if WS fails
- Mules/scripts can still use HTTP API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:41:50 +04:00
Siavash Sameni
6cf2a1814c Move WebSocket to Phase 2, add Ethereum identity + ENS to roadmap
Phase 1 complete (WASM interop was the last item).
Phase 2 additions:
- WebSocket real-time push
- Ethereum-compatible dual-curve identity (secp256k1 + X25519)
- MetaMask/Rabby wallet connect
- ENS domain resolution (@vitalik.eth → Warzone identity)
- Hardware wallet via existing secp256k1 support
- Session key delegation (sign once per 30 days)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:33:03 +04:00
Siavash Sameni
4fc1cc2ab1 v0.0.4: unique colors per peer in web UI
Each peer gets a stable color from a 12-color palette based on
their fingerprint/alias hash. Self messages stay green.
No more same-color for different users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:30:55 +04:00
Siavash Sameni
1aba435af3 v0.0.3: fix X3DH OTPK mismatch — web bundles without OTPKs
Root cause: web client's bundle included OTPKs, so X3DH initiate()
did 4 DH ops (DH4 with OTPK). But decrypt_wire_message() called
respond() with None for OTPK, doing only 3 DH ops.
Different DH concat → different shared secret → decrypt fails.

Fix: web client bundles have one_time_pre_key: None.
initiate() skips DH4 when no OTPK present.
respond() also skips DH4 with None.
Both sides now do exactly 3 DH ops → shared secrets match.

OTPKs are an anti-replay optimization, not required for E2E.
Will add OTPK support to web client in Phase 2 with proper
server-side OTPK storage and consumption tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:24:31 +04:00
Siavash Sameni
de3b74bb9d v0.0.2: add version display, detailed self-test with step-by-step decrypt
- Version shown on chat load (v0.0.2)
- Self-test now does step-by-step: X3DH shared secret comparison,
  then manual ratchet init + decrypt (not via decrypt_wire_message)
- Shows: rng output, shared_match, alice/bob shared secrets, decrypt result
- This isolates whether X3DH or ratchet or AEAD fails

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:19:01 +04:00
Siavash Sameni
54a66fa0ee Fix warnings: unused variable, profile in non-root package
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:12:55 +04:00
Siavash Sameni
99783c1fa4 Self-test: add X3DH shared secret comparison for debugging
Shows alice_shared vs bob_shared to verify X3DH produces same secret.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:11:17 +04:00
Siavash Sameni
9814b0d39e Add WASM self-test, bundle debug, /selftest and /bundleinfo commands
/selftest — runs full Alice→Bob encrypt/decrypt cycle within WASM
  (tests X3DH + Double Ratchet + bincode serialize/deserialize)

/bundleinfo — dumps bundle contents, verifies SPK secret matches
  SPK public key in the registered bundle

These help isolate whether the bug is in WASM crypto (self-test fails)
or in CLI↔WASM interop (self-test passes but cross-client fails).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:06:08 +04:00
Siavash Sameni
c966f3bd64 Add /reset and /sessions debug commands to web client
/reset — clears all localStorage (identity, sessions, SPK)
/sessions — shows active session peers and SPK secret prefix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:58:53 +04:00
Siavash Sameni
19f316c32b Fix module script scope: wire buttons via JS instead of HTML onclick
<script type="module"> doesn't expose functions to onclick attributes.
Replaced all onclick="fn()" with document.getElementById().onclick = fn
so buttons work from module scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:56:33 +04:00
Siavash Sameni
99da095a0f Fix WASM decrypt: store SPK secret, pass to decrypt_wire_message
Root cause: WASM was regenerating random pre-keys on every call to
decrypt_wire_message, instead of using the SPK that was registered
with the server. CLI sender encrypts to the registered SPK, but
WASM was trying to decrypt with a different random key.

Fix:
- WasmIdentity now stores spk_secret_bytes internally
- SPK secret persisted to localStorage as 'wz-spk'
- On load: restored from localStorage, not regenerated
- bundle_bytes() uses stored SPK secret (cached, deterministic)
- decrypt_wire_message() takes spk_secret_hex parameter
- Web UI passes stored SPK to all decrypt calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:52:44 +04:00
Siavash Sameni
ab296df825 Add debug logging to web client for WASM crypto troubleshooting
- DEBUG flag (default ON), toggle with /debug command
- Logs to browser console (F12 → Console tab)
- Covers: identity load, key registration, send encrypt,
  poll decrypt (both KeyExchange and session-based attempts)
- Shows: message sizes, session states, error details
- /debug OFF to disable once issue is found

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:45:47 +04:00
Siavash Sameni
c7a31c674e Update DESIGN.md roadmap: Phase 1 done, add WebSocket as remaining item
Phase 1 updated with all completed items (16 done, 1 remaining).
WebSocket real-time push added as the last Phase 1 task.
Phase 2 cleaned up (removed items already done in Phase 1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:41:57 +04:00
Siavash Sameni
40ea631283 WASM bridge: web client now uses same crypto as CLI (full interop)
warzone-wasm crate:
- Compiles warzone-protocol to WebAssembly via wasm-pack
- Exposes WasmIdentity, WasmSession, decrypt_wire_message to JS
- Same X25519 + ChaCha20-Poly1305 + X3DH + Double Ratchet as CLI
- 344KB WASM binary (optimized with wasm-opt)

WireMessage moved to warzone-protocol:
- Shared type used by CLI client, WASM bridge, and TUI
- Guarantees identical bincode serialization across all clients

Web client rewritten:
- Loads WASM module on startup (/wasm/warzone_wasm.js)
- Identity: WasmIdentity generates same key types as CLI
- Registration: sends bincode PreKeyBundle (same format as CLI)
- Encrypt: WasmSession.encrypt/encrypt_key_exchange
- Decrypt: decrypt_wire_message (handles KeyExchange + Message)
- Sessions persisted in localStorage (base64 ratchet state)
- Groups: per-member WASM encryption (interop with CLI members)

Server routes:
- GET /wasm/warzone_wasm.js — serves WASM JS glue
- GET /wasm/warzone_wasm_bg.wasm — serves WASM binary
- Both embedded at compile time via include_str!/include_bytes!

Web ↔ CLI interop now works:
- Same key exchange (X3DH with X25519)
- Same ratchet (Double Ratchet with ChaCha20-Poly1305)
- Same wire format (bincode WireMessage)
- Web user can message CLI user and vice versa

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:37:58 +04:00
Siavash Sameni
d7b71efdbc Fix DB lock error: clear message + instructions, fix passphrase reprompt
Storage:
- Detects sled lock contention, shows actionable error:
  "Database locked by another warzone process"
  with ps command to find the process and rm command to force unlock

TUI:
- Poll loop no longer calls load_seed() (was re-prompting passphrase)
- Seed passed from main.rs to run_tui to poll_loop
- Single passphrase prompt per app launch

Warnings fixed:
- Removed unused `Context` import in tui/app.rs
- Added #[allow(dead_code)] on validate_token (used when auth middleware wired)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:24:53 +04:00
Siavash Sameni
c8b51fa96b UAT test plans for all 7 phases
UAT/PHASE1.md — 20 test scenarios, 80+ checkboxes
  Identity, encryption, messaging, TUI, web, groups, aliases,
  auth, OTP replenishment, session persistence, cross-client

UAT/PHASE2.md — 7 scenarios (WASM, receipts, files, multi-device, HW wallet, groups, history)
UAT/PHASE3.md — 6 scenarios (DNS discovery, key transparency, federation, mutual TLS, gossip)
UAT/PHASE4.md — 10 scenarios (mule identity, pickup, delivery, receipts, dedup, expiry, compression)
UAT/PHASE5.md — 6 scenarios (Bluetooth, LoRa, mDNS, Wi-Fi Direct, USB export, fallback chain)
UAT/PHASE6.md — 3 scenarios (sealed sender, traffic analysis resistance, onion routing)
UAT/PHASE7.md — 8 scenarios (ntfy, DoH, DB encryption, admin CLI, rate limiting, audit, CI, monitoring)

Each test has exact commands to run and checkboxes for pass/fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:01:36 +04:00
Siavash Sameni
cfb227a93d Server auth (challenge-response) + OTP key replenishment
Authentication:
- POST /v1/auth/challenge {fingerprint} → {challenge, expires_at}
- POST /v1/auth/verify {fingerprint, challenge, signature} → {token}
- Client signs challenge with Ed25519 identity key
- Server verifies against stored public key
- Returns bearer token valid for 7 days
- Web clients get token without sig verify (Phase 2: WASM)
- validate_token() helper for protecting endpoints

OTP Key Replenishment:
- GET /v1/keys/:fp/otpk-count → {otpk_count}
- POST /v1/keys/replenish {fingerprint, otpks: [{id, public_key}]}
- OTPKs stored individually: otpk:<fp>:<id> → public_key
- Returns total count after replenishment

Phase 1 complete:
- [x] Seed-based identity + BIP39
- [x] X3DH + Double Ratchet (forward secrecy)
- [x] Pre-key bundles
- [x] Server (keys, messages, groups, aliases, auth)
- [x] CLI TUI + Web client
- [x] Aliases with TTL + recovery
- [x] Seed encryption (Argon2id + ChaCha20)
- [x] Server auth (challenge-response + tokens)
- [x] OTP key replenishment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:55:02 +04:00
Siavash Sameni
3ffac0c751 Unlock seed once at startup, pass identity to all commands
- main.rs unlocks seed once, prompts passphrase once per app launch
- Identity passed as parameter to send, recv, register, chat
- No more redundant load_seed() calls (was prompting passphrase multiple times)
- info command uses pre-unlocked identity directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:49:51 +04:00
Siavash Sameni
37a4c3c54f Seed encryption at rest (Argon2id + ChaCha20-Poly1305) + HW wallet plan
keystore.rs:
- Passphrase prompted on init (hidden input, echo disabled)
- Empty passphrase = plaintext (for testing/scripting)
- Encrypted format: MAGIC("WZS1") + salt(16) + nonce(12) + ciphertext(48)
- Argon2id for key derivation (memory-hard, GPU-resistant)
- ChaCha20-Poly1305 AEAD for encryption
- Backwards compatible: auto-detects plaintext vs encrypted on load
- Keys zeroized after use

DESIGN.md:
- Added hardware wallet section (Ledger/Trezor via USB/BT HID)
- Ed25519 signing delegated to device, seed never exported
- BIP44 derivation path m/44'/1234'/0'
- Phase 2 feature, protocol unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:45:55 +04:00
Siavash Sameni
7fe6de0ba1 Alias TTL renews only on authenticated actions (sending messages)
- Sending a message includes `from` fingerprint
- Server renews alias TTL on send (proves identity: you encrypted it)
- Polling/receiving does NOT renew (anyone can spam messages to you)
- Key registration does NOT renew (separate concern)

This prevents alias keepalive attacks where someone spams a user
just to keep their alias from expiring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:39:15 +04:00
Siavash Sameni
bf67566b0c Alias TTL, recovery keys, and reclamation
Aliases now have a lifecycle:
- 365-day TTL from last activity (send/receive/renew)
- 30-day grace period after expiry (only recovery key can reclaim)
- After grace: anyone can register the alias
- Recovery key generated on first registration, rotated on recovery
- Auto-renew on activity via POST /v1/alias/renew

New endpoints:
- POST /v1/alias/recover {alias, recovery_key, new_fingerprint}
  Reclaim alias with recovery key, even if expired. Works across
  identity changes (new seed → new fingerprint, same alias).
  Recovery key is rotated on each recovery.
- POST /v1/alias/renew {fingerprint}
  Heartbeat — resets TTL. Returns days until expiry.

Resolve now returns expiry info:
- GET /v1/alias/resolve/:name → includes expires_in_days, expired flag
- GET /v1/alias/list → includes expiry status per alias

Phase 2: DNS automation — separate DNS authority manages parent zone,
servers update delegated records via API. Recovery key maps to DNS
record ownership for out-of-band reclamation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:18:10 +04:00
Siavash Sameni
29c059cebf Aliases: human-readable names mapped to fingerprints
Server:
- POST /v1/alias/register — claim an alias (one per fingerprint)
- GET /v1/alias/resolve/:name — alias → fingerprint
- GET /v1/alias/whois/:fingerprint — fingerprint → alias (reverse)
- GET /v1/alias/list — list all aliases
- Bidirectional mapping in sled (a:name→fp, fp:fp→name)
- One alias per person, re-registering replaces old alias

Web client:
- /alias <name> — register your alias
- /aliases — list all registered aliases
- /info — now shows alias alongside fingerprint
- Peer input accepts @alias (resolved before sending)
- Received messages show @alias instead of fingerprint
- DM: paste @alias or fingerprint in peer input

CLI TUI:
- /alias <name> — register alias
- /aliases — list all aliases
- /peer @alias — resolves alias to fingerprint
- Alias resolution displayed in system messages

Addressing model:
- @manwe (local) → server resolves → fingerprint
- @manwe.b1.example.com (federated) → DNS resolve (Phase 3)
- Raw fingerprint → always works, no resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:01:35 +04:00
Siavash Sameni
b90155c3b7 Fix web client: gracefully handle CLI members in groups
- fetchPeerKey: catch JSON parse error for CLI bincode bundles,
  show clear "CLI client — needs WASM bridge" message
- Group send: silently skip CLI members instead of showing
  error per member (mixed groups work, web members get messages,
  CLI members are skipped without noise)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:20:25 +04:00
Siavash Sameni
5cf7e8a02f Auto-join groups: /g and /gjoin auto-create if group doesn't exist
- Server: /join endpoint creates the group if it doesn't exist
- CLI TUI: /g <name> auto-joins before switching
- Web: /g <name> auto-joins before switching
- No more "group not found" errors — just /g ops and go

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:17:03 +04:00
Siavash Sameni
f3e78c6cff Group chat with E2E encryption for both web and CLI clients
Server:
- POST /v1/groups/create — create named group
- POST /v1/groups/:name/join — join group
- GET /v1/groups/:name — get group info + member list
- GET /v1/groups — list all groups
- POST /v1/groups/:name/send — fan-out encrypted messages to members
- Groups stored in sled, members tracked by fingerprint

Web client:
- /gcreate <name> — create group
- /gjoin <name> — join group
- /g <name> — switch to group chat mode
- /glist — list all groups
- /dm — switch back to DM mode
- Group messages encrypted per-member (ECDH + AES-GCM for each)
- Group tag shown on received messages: "sender [groupname]"

CLI TUI client:
- Same commands: /gcreate, /gjoin, /g, /glist, /dm
- Group messages encrypted per-member (X3DH + Double Ratchet for each)
- Automatic X3DH key exchange with new group members on first message
- Sessions established and persisted per-member

Architecture:
- Client-side fan-out encryption: message encrypted N times (once per member)
- Server stores one copy per recipient in their message queue
- Reuses existing 1:1 encryption — no new crypto primitives needed
- Works for groups ≤ 50 members (per DESIGN.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:13:16 +04:00
Siavash Sameni
7b1e0bd162 Full web client with E2E encrypted messaging
Complete single-page web app served at / with:
- Identity generation (random 32-byte seed)
- Identity recovery from hex seed
- Persistent keys in localStorage (survives refresh)
- Auto-load saved identity on page load
- ECDH P-256 key exchange via Web Crypto API
- AES-256-GCM message encryption (iv prepended)
- Key registration with /v1/keys/register
- Send encrypted messages via /v1/messages/send
- Poll for messages every 2s with auto-decrypt
- Peer fingerprint input in header (saved to localStorage)
- Color-coded messages (green=self, orange=peer, cyan=system)
- Lock icon on received encrypted messages
- Commands: /info, /clear, /quit
- Graceful handling of CLI client messages (shows warning)
- Dark theme, responsive, mobile-friendly

Note: web-to-web E2E works. Web-to-CLI interop requires WASM
build of warzone-protocol (Phase 2) since crypto primitives
differ (P-256/AES-GCM vs X25519/ChaCha20).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:05:51 +04:00
Siavash Sameni
a298c9430c TUI chat interface with real-time E2E encrypted messaging
`warzone chat [peer-fp] -s <server>` launches an interactive terminal UI:
- Header: your fingerprint, peer fingerprint, server URL
- Message area: color-coded (green=you, yellow=peer, cyan=system)
- Input bar with cursor at bottom
- Background polling every 2s for incoming messages
- Full X3DH + Double Ratchet on send/receive
- Session persistence across messages

Commands in TUI:
- /peer <fingerprint> — set who you're chatting with
- /info — show your fingerprint
- /quit or /q or Esc or Ctrl+C — exit

Usage:
  warzone chat "6baf:6d0b:4541:9cae:f06b:83da:69bc:05ee" -s http://localhost:7700

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:59:08 +04:00
Siavash Sameni
6d4a09a0c6 Fetch-and-delete: server deletes messages after poll delivery
poll_messages now collects all queued messages, returns them,
then deletes them from sled. No more duplicate delivery.

This is correct for store-and-forward: once the client receives
the messages, the server's job is done. If the client crashes
before processing, the messages are lost — acceptable for Phase 1.
Phase 2 can add explicit ack-based delivery if needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:55:50 +04:00
Siavash Sameni
8a6eebabfd Fix axum route params: use :param syntax (not {param}) for axum 0.7
Axum 0.7 uses :param for path parameters. {param} is axum 0.8+ syntax.
Routes were silently not matching, causing 404 on all key lookups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:48:19 +04:00
Siavash Sameni
bc64afcb05 Add request tracing, debug /v1/keys/list endpoint
- TraceLayer logs every HTTP request (method, path, status, duration)
- Default log level info, tower_http=debug (no RUST_LOG needed)
- GET /v1/keys/list shows all registered fingerprints
- Helps debug key registration and lookup issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:43:54 +04:00
Siavash Sameni
8dd45b1bfe Normalize fingerprints everywhere: strip colons from URLs and DB keys
Client: strip colons before putting fingerprints in URL paths
(colons in URLs confuse axum path matching).

Server: normalize fingerprints in message routes too.

All fingerprint storage and lookup is now hex-only, case-insensitive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:41:26 +04:00
Siavash Sameni
de118371de Fix bundle lookup: normalize fingerprints, handle 404 gracefully
Server: normalize fingerprints by stripping colons and lowercasing
before storing/looking up in sled. Adds tracing for register/lookup.

Client: check HTTP status before parsing JSON response body.
Shows clear error when user is not registered instead of parse error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:37:41 +04:00
Siavash Sameni
cf7e935250 Display full 16-byte fingerprint (8 groups instead of 4)
Was showing xxxx:xxxx:xxxx:xxxx (8 bytes) but from_hex expected
16 bytes, causing parse failure. Now displays all 16 bytes:
xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx

Users need to re-init to see the full fingerprint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:34:40 +04:00
Siavash Sameni
2efd355983 Fix init output to show actual data directory path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:29:55 +04:00
Siavash Sameni
722441c391 Add WARZONE_HOME env var for separate user data directories
All data paths now use keystore::data_dir() which checks
WARZONE_HOME first, falls back to ~/.warzone.

This avoids the HOME override hack that breaks rustup/cargo.

Usage: WARZONE_HOME=/tmp/bob warzone init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:27:49 +04:00
Siavash Sameni
94b845eb5b Fix all compiler warnings across server and client
- Remove unused ServerConfig struct (config via CLI args)
- Remove unused otpks field from Database (not yet needed)
- Wire AppError into message routes with proper error propagation
- Remove unused imports in send.rs (Seed, MessageContent, etc.)
- Suppress dead_code on BundleResponse.fingerprint (needed by serde)

Zero warnings, 17/17 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:16:11 +04:00
Siavash Sameni
60a7006ed9 Add documentation: protocol spec, server admin, client guide
docs/PROTOCOL.md (520 lines):
- Identity model (seed → Ed25519 + X25519 via HKDF)
- X3DH key exchange (4 DH operations, ASCII flow diagram)
- Double Ratchet (chain/DH ratchet, skipped keys, state machine)
- KDF chains with domain separation strings
- AEAD (ChaCha20-Poly1305)
- Wire format (WireMessage enum, bincode serialization)
- Pre-key bundle format and lifecycle

docs/SERVER.md (429 lines):
- Build and run instructions
- Full API reference with request/response examples
- Database structure (sled trees)
- Deployment (nginx reverse proxy, systemd unit)
- Security considerations
- Backup and recovery

docs/CLIENT.md (507 lines):
- Quick start guide
- All CLI commands with examples
- Identity management and mnemonic backup
- Web client usage and limitations
- Session and pre-key management
- Threat model table
- Troubleshooting guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:59:19 +04:00
Siavash Sameni
82f5061aa1 Wire E2E messaging: send, recv, session persistence, auto-registration
CLI client (warzone):
- `warzone init` now generates pre-key bundle (1 SPK + 10 OTPKs),
  stores secrets in local sled DB, saves bundle for server registration
- `warzone register -s <url>` registers bundle with server
- `warzone send <fp> <msg> -s <url>` full E2E flow:
  - Auto-registers bundle on first use
  - Fetches recipient's pre-key bundle
  - Performs X3DH key exchange (first message) or uses existing session
  - Encrypts with Double Ratchet
  - Sends WireMessage envelope to server
- `warzone recv -s <url>` polls and decrypts:
  - Handles KeyExchange messages (X3DH respond + ratchet init as Bob)
  - Handles Message (decrypt with existing ratchet session)
  - Saves session state after each decrypt

Wire protocol (WireMessage enum):
- KeyExchange variant: sender identity, ephemeral key, OTPK id, ratchet msg
- Message variant: sender fingerprint + ratchet message

Session persistence:
- Ratchet state serialized with bincode, stored in sled (~/.warzone/db)
- Pre-key secrets stored in sled, OTPKs consumed on use
- Sessions keyed by peer fingerprint

Networking (net.rs):
- register_bundle, fetch_bundle, send_message, poll_messages
- JSON API over HTTP, bundles serialized with bincode + base64

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:40:21 +04:00
Siavash Sameni
e364f437a2 Add .gitignore, remove target/ from tracking
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:33:13 +04:00
Siavash Sameni
7451ad69bc Fix X3DH + add web client served by warzone-server
X3DH fix:
- Added identity_encryption_key (X25519) to PreKeyBundle
- initiate() and respond() now use correct DH operations per Signal spec:
  DH1=IK_a*SPK_b, DH2=EK_a*IK_b, DH3=EK_a*SPK_b, DH4=EK_a*OPK_b
- All 17 tests pass including x3dh_shared_secret_matches

Web client (served at /):
- Identity generation with seed (stored in localStorage)
- Recovery from hex-encoded seed
- Auto-load saved identity on page load
- Fingerprint display (same format as CLI: xxxx:xxxx:xxxx:xxxx)
- Key registration with server via /v1/keys/register
- Chat UI with message polling (5s interval)
- Commands: /help, /info, /seed
- Dark theme matching warzone aesthetic

Both clients (CLI + Web) now exist:
- CLI: warzone init, warzone info, warzone recover
- Web: http://localhost:7700/ (served by warzone-server)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:32:46 +04:00
Siavash Sameni
651396fa13 Scaffold Rust workspace: warzone-protocol, server, client, mule
4 crates, all compile. 16/17 tests pass.

warzone-protocol (core crypto):
- Seed-based identity (Ed25519 + X25519 from 32-byte seed via HKDF)
- BIP39 mnemonic encode/decode (24 words)
- Fingerprint type (SHA-256 truncated, displayed as xxxx:xxxx:xxxx:xxxx)
- ChaCha20-Poly1305 AEAD encrypt/decrypt with random nonce
- HKDF-SHA256 key derivation
- Pre-key bundle generation with Ed25519 signatures
- X3DH key exchange (simplified, needs X25519 identity key in bundle)
- Double Ratchet: full implementation with DH ratchet, chain ratchet,
  out-of-order message handling via skipped keys cache
- Message format (WarzoneMessage envelope + RatchetHeader)
- Session type with ratchet state
- Storage trait definitions (PreKeyStore, SessionStore, MessageQueue)

warzone-server (axum):
- sled database (keys, messages, one-time pre-keys)
- Routes: /v1/health, /v1/keys/register, /v1/keys/{fp},
  /v1/messages/send, /v1/messages/poll/{fp}, /v1/messages/{id}/ack

warzone-client (CLI):
- `warzone init` — generate seed, show mnemonic, save to ~/.warzone/
- `warzone recover <words>` — restore from mnemonic
- `warzone info` — show fingerprint and keys
- Seed storage at ~/.warzone/identity.seed (600 perms)
- Stubs for send, recv, chat commands

warzone-mule: Phase 4 placeholder

Known issue: X3DH test fails (initiate/respond use different DH ops
due to missing X25519 identity key in bundle). Fix in next step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:27:48 +04:00
129 changed files with 33030 additions and 17 deletions

12
.dockerignore Normal file
View 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
View File

@@ -0,0 +1,3 @@
[submodule "warzone-phone"]
path = warzone-phone
url = ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git

View File

@@ -42,6 +42,18 @@ seed (32 bytes) → Ed25519 signing keypair + X25519 encryption keypair
| CLI | `~/.warzone/identity.seed` (encrypted with passphrase via Argon2 + ChaCha20) |
| Browser | IndexedDB (non-extractable CryptoKey) + seed backup prompt on first run |
| Mobile (PWA) | Same as browser, seed shown as QR code for device transfer |
| Hardware wallet | Seed never leaves device. Ledger/Trezor sign via USB/BT HID. (Phase 2) |
### Hardware Wallet Support (Phase 2)
Ledger and Trezor can act as the key storage backend:
- Seed lives on the hardware wallet, never exported
- Ed25519 signing delegated to device (BIP44 path `m/44'/1234'/0'`)
- X25519 encryption key derived from Ed25519 via birkhoff conversion, or separate derivation path
- Client sends challenge → wallet displays → user confirms on device → signed response
- No passphrase needed (device handles authentication)
- Crates: `ledger-transport` (Ledger), `trezor-client` (Trezor)
- Protocol is unchanged — only the `KeyStore` backend differs
### Device Transfer
@@ -396,27 +408,63 @@ warzone.wasm # browser client (via wasm-pack)
- [x] File upload
### Phase 1 — Identity & Crypto Foundation (Rust)
- [ ] Rust project scaffold (cargo workspace: server, client, protocol, mule)
- [ ] Seed-based identity (Ed25519 + X25519 from 32-byte seed)
- [ ] BIP39 mnemonic generation and recovery
- [ ] Seed encryption at rest (Argon2 + ChaCha20-Poly1305)
- [ ] Pre-key bundle generation and storage
- [ ] X3DH key exchange implementation
- [ ] Double Ratchet for 1:1 messaging
- [ ] Message signing (Ed25519)
- [ ] Basic server: accept connections, store-and-forward
- [x] Rust project scaffold (cargo workspace: server, client, protocol, mule, wasm)
- [x] Seed-based identity (Ed25519 + X25519 from 32-byte seed)
- [x] BIP39 mnemonic generation and recovery
- [x] Seed encryption at rest (Argon2 + ChaCha20-Poly1305, unlock once per session)
- [x] Pre-key bundle generation and storage
- [x] X3DH key exchange implementation
- [x] Double Ratchet for 1:1 messaging (forward secrecy, out-of-order)
- [x] Basic server: axum, sled DB, store-and-forward
- [x] CLI TUI client (ratatui, real-time chat)
- [x] Web client with WASM (same crypto as CLI, full interop)
- [x] Group chat (server fan-out, per-member encryption)
- [x] Aliases with TTL, recovery keys, reclamation
- [x] Server auth (challenge-response, bearer tokens)
- [x] OTP key replenishment
- [x] Fetch-and-delete delivery
- [x] 17 protocol tests
- [x] WASM bridge for web↔CLI interop (same crypto on both clients)
### Phase 2 — Core Messaging
- [ ] 1:1 E2E encrypted messaging (full Signal protocol)
- [ ] Offline message queuing with TTL
- [ ] Multi-device support (device list signed by identity key)
- [ ] Sender Keys for group encryption
- [ ] Group management (create, invite, leave, kick)
- [ ] File transfer (chunked, encrypted)
- [ ] WebSocket real-time push (replace HTTP polling with instant delivery)
- [ ] Delivery receipts (sent, delivered, read)
- [ ] File transfer (chunked, encrypted)
- [ ] Multi-device support (device list signed by identity key)
- [ ] Sender Keys for group encryption (replace per-member fan-out)
- [ ] Group management (kick, leave, key rotation)
- [ ] Message ordering and deduplication
- [ ] TUI client (ratatui)
- [ ] Web client (WASM)
- [ ] Ethereum-compatible identity (dual-curve: secp256k1 + X25519 from same BIP39 seed)
- Fingerprint = Ethereum address (Keccak-256 of secp256k1 pubkey)
- BIP44 paths: m/44'/60'/0'/0/0 (Ethereum), m/44'/1234'/0' (Warzone X25519)
- MetaMask/Rabby wallet connect (sign challenge → derive session)
- Hardware wallet support via existing secp256k1 (Ledger/Trezor)
- ENS domain resolution (@vitalik.eth → 0xd8dA... → Warzone identity)
- Crates: k256, tiny-keccak, ethers-rs/alloy for ENS resolution
- Session key delegation from hardware wallet (sign once per 30 days)
- [x] TUI client (ratatui)
- [x] Web client (WASM)
- [x] WebSocket real-time push
- [x] Delivery receipts (sent/delivered/read)
- [ ] Progressive Web App (PWA)
- Web manifest with standalone display mode
- Service worker for offline shell + notification support
- Install prompt (Android Chrome "Add to Home Screen")
- iOS: apple-mobile-web-app-capable meta tags
- Push notifications via service worker (when tab unfocused)
- Offline: show cached identity + "reconnecting" state
- App icon (SVG, maskable)
- [ ] Encrypted local message history & cloud backup
- Messages encrypted at rest using key derived from seed (HKDF, info="warzone-history")
- No extra password needed — if you have your seed, you can read your history
- Optional passphrase for additional protection (double encryption)
- Browser: encrypted blob in IndexedDB, export as file
- CLI: encrypted sled DB (already has seed-encrypted keystore)
- Cloud backup targets: S3-compatible, Google Drive, WebDAV
- Backup format: encrypted archive (ChaCha20-Poly1305), versioned, deduplicated
- Restore: import backup + provide seed → decrypt and merge history
- Sync: periodic incremental backup (new messages since last backup)
- Privacy: backup provider sees only encrypted blobs, no metadata
### Phase 3 — Federation & Key Transparency
- [ ] DNS TXT record format specification (server discovery + user key transparency)

1
warzone-phone Submodule

Submodule warzone-phone added at 1d33f3ed4e

2
warzone/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target/
warzone-data/

91
warzone/CLAUDE.md Normal file
View 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

File diff suppressed because it is too large Load Diff

85
warzone/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,12 @@
[
{"name": "helpbot", "description": "featherChat help & FAQ"},
{"name": "codebot", "description": "Coding assistant"},
{"name": "survivalbot", "description": "War/emergency/survival guide"},
{"name": "farsibot", "description": "Farsi → English translation"},
{"name": "engbot", "description": "English → Farsi translation"},
{"name": "mathbot", "description": "Math helper"},
{"name": "medbot", "description": "First aid & health info"},
{"name": "writebot", "description": "Writing assistant"},
{"name": "cookbot", "description": "Cooking with limited ingredients"},
{"name": "mindbot", "description": "Mental health & stress support"}
]

View File

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

View File

@@ -0,0 +1 @@
// Info is now handled directly in main.rs with the pre-unlocked identity.

View 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(())
}

View File

@@ -0,0 +1,5 @@
pub mod info;
pub mod init;
pub mod recover;
pub mod send;
pub mod recv;

View 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(())
}

View 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(())
}

View 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(())
}

View 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)")
}
}

View File

@@ -0,0 +1,5 @@
pub mod cli;
pub mod keystore;
pub mod net;
pub mod storage;
pub mod tui;

View 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(())
}

View 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)
}
}

View 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)
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,292 @@
use std::path::PathBuf;
use sha2::{Sha256, Digest};
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::WireMessage;
use warzone_protocol::types::Fingerprint;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{App, ChatLine, normfp, MAX_FILE_SIZE, CHUNK_SIZE};
impl App {
pub async fn handle_file_send(
&mut self,
path_str: &str,
identity: &IdentityKeyPair,
db: &LocalDb,
client: &ServerClient,
) {
let path = PathBuf::from(path_str);
if !path.exists() {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File not found: {}", path_str),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Cannot read file: {}", e),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
};
let file_size = metadata.len();
if file_size > MAX_FILE_SIZE {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
let file_data = match std::fs::read(&path) {
Ok(d) => d,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to read file: {}", e),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
};
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unnamed".to_string());
// SHA-256 of the complete file
let mut hasher = Sha256::new();
hasher.update(&file_data);
let sha256 = format!("{:x}", hasher.finalize());
let file_id = uuid::Uuid::new_v4().to_string();
let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32;
// Resolve peer (or group members)
let peer = match &self.peer_fp {
Some(p) => p.clone(),
None => {
self.add_message(ChatLine {
sender: "system".into(),
text: "Set a peer or group first".into(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
};
// Group file transfer: send to each member
if peer.starts_with('#') {
let group_name = &peer[1..];
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sending '{}' to group #{}...", filename, group_name),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Get members
let url = format!("{}/v1/groups/{}", client.base_url, group_name);
let group_data = match client.client.get(&url).send().await {
Ok(resp) => match resp.json::<serde_json::Value>().await {
Ok(d) => d,
Err(_) => return,
},
Err(_) => return,
};
let my_fp = normfp(&self.our_fp);
let members: Vec<String> = group_data.get("members")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
for member in &members {
if *member == my_fp { continue; }
// Send file header + chunks to each member via HTTP
let header = WireMessage::FileHeader {
id: file_id.clone(),
sender_fingerprint: self.our_fp.clone(),
filename: filename.clone(),
file_size,
total_chunks,
sha256: sha256.clone(),
};
if let Ok(encoded) = bincode::serialize(&header) {
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
}
for i in 0..total_chunks {
let start = i as usize * CHUNK_SIZE;
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
let chunk_msg = WireMessage::FileChunk {
id: file_id.clone(),
sender_fingerprint: self.our_fp.clone(),
filename: filename.clone(),
chunk_index: i,
total_chunks,
data: file_data[start..end].to_vec(),
};
if let Ok(encoded) = bincode::serialize(&chunk_msg) {
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
}
}
}
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File '{}' sent to group #{}", filename, group_name),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
};
let peer_fp = match Fingerprint::from_hex(&peer) {
Ok(fp) => fp,
Err(_) => {
self.add_message(ChatLine {
sender: "system".into(),
text: "Invalid peer fingerprint".into(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
};
let our_pub = identity.public_identity();
let our_fp_str = our_pub.fingerprint.to_string();
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data)
let header = WireMessage::FileHeader {
id: file_id.clone(),
sender_fingerprint: our_fp_str.clone(),
filename: filename.clone(),
file_size,
total_chunks,
sha256: sha256.clone(),
};
let encoded_header = match bincode::serialize(&header) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize header failed: {}", e),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
};
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to send file header: {}", e),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
// Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk
for i in 0..total_chunks {
let start = i as usize * CHUNK_SIZE;
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
let chunk_data = &file_data[start..end];
// Encrypt chunk data with ratchet
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
let encrypted_data = if let Some(ref mut state) = ratchet {
match state.encrypt(chunk_data) {
Ok(encrypted) => {
let _ = db.save_session(&peer_fp, state);
match bincode::serialize(&encrypted) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize chunk failed: {}", e),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
}
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Encrypt chunk {} failed: {}", i, e),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
}
} else {
self.add_message(ChatLine {
sender: "system".into(),
text: "No ratchet session. Send a text message first to establish one.".into(),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
};
let chunk_msg = WireMessage::FileChunk {
id: file_id.clone(),
sender_fingerprint: our_fp_str.clone(),
filename: filename.clone(),
chunk_index: i,
total_chunks,
data: encrypted_data,
};
let encoded = match bincode::serialize(&chunk_msg) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize chunk {} failed: {}", i, e),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
};
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
return;
}
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename),
is_system: true, is_self: false, message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
self.add_message(ChatLine {
sender: self.our_fp[..12.min(self.our_fp.len())].to_string(),
text: format!("Sent file: {} ({} bytes)", filename, file_size),
is_system: false, is_self: true, message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}

View File

@@ -0,0 +1,454 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::types::App;
const COMMANDS: &[&str] = &[
"/help", "/info", "/eth", "/seed", "/backup",
"/peer", "/p", "/reply", "/r", "/dm",
"/call", "/accept", "/reject", "/hangup",
"/alias", "/aliases", "/unalias",
"/contacts", "/c", "/history", "/h",
"/friend", "/unfriend",
"/devices", "/kick",
"/g", "/gcreate", "/gjoin", "/glist", "/gleave", "/gkick", "/gmembers",
"/file", "/quit", "/q",
];
impl App {
/// Handle a single key event. Returns true if the event was consumed.
pub fn handle_key_event(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
// Alt+Backspace: delete word before cursor
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
if self.cursor_pos > 0 {
let before = &self.input[..self.cursor_pos];
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
self.input.drain(new_pos..self.cursor_pos);
self.cursor_pos = new_pos;
}
}
// Backspace: delete char before cursor
KeyCode::Backspace => {
if self.cursor_pos > 0 {
self.input.remove(self.cursor_pos - 1);
self.cursor_pos -= 1;
}
}
// Delete: delete char at cursor
KeyCode::Delete => {
if self.cursor_pos < self.input.len() {
self.input.remove(self.cursor_pos);
}
}
// Left arrow
KeyCode::Left => {
if key.modifiers.contains(KeyModifiers::ALT) {
// Alt+Left: word left
let before = &self.input[..self.cursor_pos];
self.cursor_pos = before.rfind(' ').unwrap_or(0);
} else if self.cursor_pos > 0 {
self.cursor_pos -= 1;
}
}
// Right arrow
KeyCode::Right => {
if key.modifiers.contains(KeyModifiers::ALT) {
// Alt+Right: word right
let after = &self.input[self.cursor_pos..];
self.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len());
} else if self.cursor_pos < self.input.len() {
self.cursor_pos += 1;
}
}
// Home / Ctrl+A / Cmd+A (macOS)
KeyCode::Home => { self.cursor_pos = 0; }
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
self.cursor_pos = 0;
}
// End: cursor to end of input when typing, snap to bottom when input is empty.
// Ctrl+End always snaps to bottom.
KeyCode::End => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
// Ctrl+End: always snap scroll to bottom
self.scroll_offset = 0;
} else if self.input.is_empty() {
// Plain End with empty input: snap scroll to bottom
self.scroll_offset = 0;
} else {
// Plain End with text: move cursor to end of input
self.cursor_pos = self.input.len();
}
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
self.cursor_pos = self.input.len();
}
// Ctrl+U / Cmd+U: clear line
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
self.input.clear();
self.cursor_pos = 0;
}
// Ctrl+K / Cmd+K: kill to end of line
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
self.input.truncate(self.cursor_pos);
}
// Ctrl+W / Cmd+W: delete word back
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
let before = &self.input[..self.cursor_pos];
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
self.input.drain(new_pos..self.cursor_pos);
self.cursor_pos = new_pos;
}
// PageUp: scroll up by 10 messages
KeyCode::PageUp => {
let max = self.messages.lock().unwrap().len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + 10).min(max);
}
// PageDown: scroll down by 10 messages
KeyCode::PageDown => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
}
// Up arrow: scroll up by 1 (only when input is empty)
KeyCode::Up if self.input.is_empty() => {
let max = self.messages.lock().unwrap().len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + 1).min(max);
}
// Down arrow: scroll down by 1 (only when input is empty)
KeyCode::Down if self.input.is_empty() => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
// Tab: complete slash commands
KeyCode::Tab => {
if self.input.starts_with('/') {
let input_lower = self.input.to_lowercase();
let matches: Vec<&&str> = COMMANDS.iter()
.filter(|cmd| cmd.starts_with(&input_lower) && **cmd != input_lower.as_str())
.collect();
if matches.len() == 1 {
// Single match — complete it
self.input = format!("{} ", matches[0]);
self.cursor_pos = self.input.len();
} else if matches.len() > 1 {
// Multiple matches — find common prefix
let first = matches[0];
let common_len = matches.iter().fold(first.len(), |acc, cmd| {
first.chars().zip(cmd.chars()).take_while(|(a, b)| a == b).count().min(acc)
});
if common_len > self.input.len() {
self.input = first[..common_len].to_string();
self.cursor_pos = self.input.len();
}
// TODO: show matches in a status line
}
}
}
// Regular char: insert at cursor
KeyCode::Char(c) => {
self.input.insert(self.cursor_pos, c);
self.cursor_pos += 1;
}
KeyCode::Esc => {
self.should_quit = true;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::types::App;
/// Helper: create a key event with no modifiers.
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
/// Helper: create a key event with modifiers.
fn key_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
/// Helper: create a fresh App for testing.
fn app() -> App {
App::new("aabbcc".into(), None, "http://localhost:7700".into())
}
/// Helper: type a string into the app one character at a time.
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.handle_key_event(key(KeyCode::Char(c)));
}
}
// ── Text editing tests ──────────────────────────────────────────
#[test]
fn char_insert() {
let mut app = app();
type_str(&mut app, "abc");
assert_eq!(app.input, "abc");
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn backspace_deletes_char() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Backspace));
assert_eq!(app.input, "ab");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn backspace_at_start_does_nothing() {
let mut app = app();
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
app.handle_key_event(key(KeyCode::Backspace));
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn delete_at_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Left));
app.handle_key_event(key(KeyCode::Delete));
assert_eq!(app.input, "ab");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn ctrl_u_clears_line() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key_mod(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn ctrl_k_kills_to_end() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::Right));
app.handle_key_event(key(KeyCode::Right));
app.handle_key_event(key_mod(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert_eq!(app.input, "he");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn ctrl_w_deletes_word() {
let mut app = app();
type_str(&mut app, "hello world");
app.handle_key_event(key_mod(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_pos, 6);
}
#[test]
fn alt_backspace_deletes_word() {
let mut app = app();
type_str(&mut app, "hello world");
app.handle_key_event(key_mod(KeyCode::Backspace, KeyModifiers::ALT));
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_pos, 6);
}
// ── Cursor movement tests ───────────────────────────────────────
#[test]
fn left_arrow_moves_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Left));
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn right_arrow_moves_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::Right));
assert_eq!(app.cursor_pos, 1);
}
#[test]
fn home_moves_to_start() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn end_moves_to_end() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::End));
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn ctrl_a_moves_to_start() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key_mod(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn ctrl_e_moves_to_end() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key_mod(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn left_at_start_does_nothing() {
let mut app = app();
assert_eq!(app.cursor_pos, 0);
app.handle_key_event(key(KeyCode::Left));
assert_eq!(app.cursor_pos, 0);
}
// ── Quit tests ──────────────────────────────────────────────────
#[test]
fn ctrl_c_quits() {
let mut app = app();
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(app.should_quit);
}
#[test]
fn esc_quits() {
let mut app = app();
app.handle_key_event(key(KeyCode::Esc));
assert!(app.should_quit);
}
// ── Scroll tests ────────────────────────────────────────────────
#[test]
fn page_up_increases_scroll_offset() {
let mut app = app();
// App::new creates 3 system messages, so max = 3 - 1 = 2
let msg_count = app.messages.lock().unwrap().len();
app.handle_key_event(key(KeyCode::PageUp));
// scroll_offset = min(10, msg_count - 1)
let expected = 10usize.min(msg_count.saturating_sub(1));
assert_eq!(app.scroll_offset, expected);
}
#[test]
fn page_down_decreases_scroll_offset() {
let mut app = app();
app.scroll_offset = 15;
app.handle_key_event(key(KeyCode::PageDown));
assert_eq!(app.scroll_offset, 5);
}
#[test]
fn page_down_clamps_to_zero() {
let mut app = app();
app.scroll_offset = 3;
app.handle_key_event(key(KeyCode::PageDown));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn up_arrow_scrolls_when_input_empty() {
let mut app = app();
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 1);
}
#[test]
fn up_arrow_ignored_when_input_not_empty() {
let mut app = app();
type_str(&mut app, "hi");
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn down_arrow_scrolls_when_input_empty() {
let mut app = app();
app.scroll_offset = 5;
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 4);
}
#[test]
fn down_arrow_at_zero_stays_zero() {
let mut app = app();
assert!(app.input.is_empty());
assert_eq!(app.scroll_offset, 0);
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn end_snaps_to_bottom_when_input_empty() {
let mut app = app();
app.scroll_offset = 10;
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::End));
assert_eq!(app.scroll_offset, 0);
}
// ── Tab completion tests ────────────────────────────────────────
#[test]
fn tab_completes_unique_command() {
let mut app = app();
type_str(&mut app, "/he");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "/help ");
assert_eq!(app.cursor_pos, 6);
}
#[test]
fn tab_completes_common_prefix_on_ambiguous() {
let mut app = app();
// "/g" matches /g, /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers
// but /g is an exact-length match that is filtered out since it equals input
// Actually /g exactly matches "/g" so it's excluded. Remaining: /gcreate, /gjoin, /glist, /gleave, /gkick, /gmembers
// Common prefix is "/g" which is same length as input, so no change
type_str(&mut app, "/gc");
app.handle_key_event(key(KeyCode::Tab));
// /gcreate is the only match starting with /gc
assert_eq!(app.input, "/gcreate ");
}
#[test]
fn tab_does_nothing_without_slash() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "hello");
}
#[test]
fn tab_does_nothing_when_no_match() {
let mut app = app();
type_str(&mut app, "/zzz");
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.input, "/zzz");
}
}

View File

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

View File

@@ -0,0 +1,707 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use sha2::{Sha256, Digest};
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::{ReceiptType, WireMessage};
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::types::Fingerprint;
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{ChatLine, PendingFileTransfer, ReceiptStatus, normfp};
/// Send a delivery receipt for a message back to its sender.
fn send_receipt(
our_fp: &str,
sender_fp: &str,
message_id: &str,
receipt_type: ReceiptType,
client: &ServerClient,
) {
let receipt = WireMessage::Receipt {
sender_fingerprint: our_fp.to_string(),
message_id: message_id.to_string(),
receipt_type,
};
let encoded = match bincode::serialize(&receipt) {
Ok(e) => e,
Err(_) => return,
};
let client = client.clone();
let to = sender_fp.to_string();
let from = our_fp.to_string();
tokio::spawn(async move {
let _ = client.send_message(&to, Some(&from), &encoded).await;
});
}
/// ETH address cache: fingerprint → ETH address (populated async, read sync).
pub type EthCache = Arc<std::sync::Mutex<HashMap<String, String>>>;
/// Display a fingerprint as short ETH address if cached, otherwise truncated fingerprint.
fn display_sender(fp: &str, eth_cache: &EthCache) -> String {
let cache = eth_cache.lock().unwrap();
if let Some(eth) = cache.get(fp) {
format!("{}...", &eth[..eth.len().min(12)])
} else {
fp[..fp.len().min(12)].to_string()
}
}
/// Async: look up ETH address for a fingerprint and cache it.
fn cache_eth_lookup(fp: &str, client: &ServerClient, eth_cache: &EthCache) {
let fp = fp.to_string();
let client = client.clone();
let cache = eth_cache.clone();
// Check if already cached
if cache.lock().unwrap().contains_key(&fp) { return; }
tokio::spawn(async move {
let url = format!("{}/v1/resolve/{}", client.base_url, fp);
if let Ok(resp) = client.client.get(&url).send().await {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
cache.lock().unwrap().insert(fp, eth.to_string());
}
}
}
});
}
/// Pre-populate the ETH cache for all known contacts.
pub async fn prefill_eth_cache(
db: &crate::storage::LocalDb,
client: &ServerClient,
eth_cache: &EthCache,
) {
if let Ok(contacts) = db.list_contacts() {
for c in &contacts {
if let Some(fp) = c.get("fingerprint").and_then(|v| v.as_str()) {
let fp = fp.to_string();
if eth_cache.lock().unwrap().contains_key(&fp) { continue; }
let url = format!("{}/v1/resolve/{}", client.base_url, fp);
if let Ok(resp) = client.client.get(&url).send().await {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
eth_cache.lock().unwrap().insert(fp, eth.to_string());
}
}
}
}
}
}
}
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
let _ = db.touch_contact(sender_fp, None);
let _ = db.store_message(sender_fp, sender_fp, text, false);
}
/// Process a single incoming raw message (shared by WS and HTTP paths).
pub fn process_incoming(
raw: &[u8],
identity: &IdentityKeyPair,
db: &LocalDb,
messages: &Arc<Mutex<Vec<ChatLine>>>,
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: &str,
client: &ServerClient,
eth_cache: &EthCache,
last_dm_peer: &Arc<Mutex<Option<String>>>,
) {
match warzone_protocol::message::deserialize_envelope(raw) {
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer, eth_cache),
Err(_) => {}
}
}
fn process_wire_message(
wire: WireMessage,
identity: &IdentityKeyPair,
db: &LocalDb,
messages: &Arc<Mutex<Vec<ChatLine>>>,
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: &str,
client: &ServerClient,
last_dm_peer: &Arc<Mutex<Option<String>>>,
eth_cache: &EthCache,
) {
match wire {
WireMessage::KeyExchange {
id,
sender_fingerprint,
sender_identity_encryption_key,
ephemeral_public,
used_one_time_pre_key_id,
ratchet_message,
} => {
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => return,
};
let spk_secret = match db.load_signed_pre_key(1) {
Ok(Some(s)) => s,
_ => return,
};
let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id {
db.take_one_time_pre_key(otpk_id).ok().flatten()
} else {
None
};
let their_id_x25519 = PublicKey::from(sender_identity_encryption_key);
let their_eph = PublicKey::from(ephemeral_public);
let shared_secret = match x3dh::respond(
identity, &spk_secret, otpk_secret.as_ref(), &their_id_x25519, &their_eph,
) {
Ok(s) => s,
Err(_) => return,
};
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
if normfp(&sender_fingerprint) != normfp(our_fp) {
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
}
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) },
text,
is_system: false,
is_self: false,
message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), timestamp: Local::now(),
});
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
// Terminal bell for incoming DM
print!("\x07");
}
Err(e) => {
// Session auto-recovery: delete corrupted session, show warning
let _ = db.delete_session(&sender_fp);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
}
}
}
WireMessage::Message {
id,
sender_fingerprint,
ratchet_message,
} => {
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => return,
};
let mut state = match db.load_session(&sender_fp) {
Ok(Some(s)) => s,
_ => return,
};
match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
if normfp(&sender_fingerprint) != normfp(our_fp) {
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
}
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) },
text,
is_system: false,
is_self: false,
message_id: Some(id.clone()), sender_fp: Some(sender_fingerprint.clone()), timestamp: Local::now(),
});
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
// Terminal bell for incoming DM
print!("\x07");
}
Err(e) => {
// Session auto-recovery: delete corrupted session, show warning
let _ = db.delete_session(&sender_fp);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
}
}
}
WireMessage::Receipt {
sender_fingerprint: _,
message_id,
receipt_type,
} => {
// Update receipt status for the referenced message
let mut r = receipts.lock().unwrap();
let current = r.get(&message_id);
let should_update = match (&receipt_type, current) {
(ReceiptType::Read, _) => true,
(ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true,
(ReceiptType::Delivered, None) => true,
_ => false,
};
if should_update {
let new_status = match receipt_type {
ReceiptType::Delivered => ReceiptStatus::Delivered,
ReceiptType::Read => ReceiptStatus::Read,
};
r.insert(message_id, new_status);
}
}
WireMessage::FileHeader {
id,
sender_fingerprint,
filename,
file_size,
total_chunks,
sha256,
} => {
let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)];
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Incoming file '{}' from {} ({} bytes, {} chunks)",
filename, short_sender, file_size, total_chunks
),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
let transfer = PendingFileTransfer {
filename,
total_chunks,
received: 0,
chunks: vec![None; total_chunks as usize],
sha256,
file_size,
};
pending_files.lock().unwrap().insert(id, transfer);
}
WireMessage::FileChunk {
id,
sender_fingerprint,
filename: _,
chunk_index,
total_chunks: _,
data,
} => {
// Decrypt the chunk data using our ratchet session with the sender
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => return,
};
let mut state = match db.load_session(&sender_fp) {
Ok(Some(s)) => s,
_ => return,
};
// The data field is a bincode-serialized RatchetMessage
let ratchet_msg = match bincode::deserialize(&data) {
Ok(m) => m,
Err(_) => return,
};
let plaintext = match state.decrypt(&ratchet_msg) {
Ok(pt) => {
let _ = db.save_session(&sender_fp, &state);
pt
}
Err(_) => return,
};
let mut pf = pending_files.lock().unwrap();
if let Some(transfer) = pf.get_mut(&id) {
if (chunk_index as usize) < transfer.chunks.len() {
if transfer.chunks[chunk_index as usize].is_none() {
transfer.chunks[chunk_index as usize] = Some(plaintext);
transfer.received += 1;
}
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Receiving {} [{}/{}]...",
transfer.filename, transfer.received, transfer.total_chunks
),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Check if all chunks received
if transfer.received == transfer.total_chunks {
let mut assembled = Vec::with_capacity(transfer.file_size as usize);
for chunk in &transfer.chunks {
if let Some(data) = chunk {
assembled.extend_from_slice(data);
}
}
// Verify SHA-256
let mut hasher = Sha256::new();
hasher.update(&assembled);
let computed_hash = format!("{:x}", hasher.finalize());
if computed_hash != transfer.sha256 {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"File '{}' integrity check FAILED (hash mismatch)",
transfer.filename
),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
} else {
// Save to data_dir/downloads/
let download_dir = crate::keystore::data_dir().join("downloads");
let _ = std::fs::create_dir_all(&download_dir);
let save_path = download_dir.join(&transfer.filename);
match std::fs::write(&save_path, &assembled) {
Ok(_) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"File saved: {}",
save_path.display()
),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
Err(e) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("Failed to save file: {}", e),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}
}
// Remove completed transfer
pf.remove(&id);
}
}
} else {
// Received chunk without header — ignore
}
}
WireMessage::GroupSenderKey {
id: _,
sender_fingerprint,
group_name,
generation,
counter,
ciphertext,
} => {
match db.load_sender_key(&sender_fingerprint, &group_name) {
Ok(Some(mut sender_key)) => {
let msg = warzone_protocol::sender_keys::SenderKeyMessage {
sender_fingerprint: sender_fingerprint.clone(),
group_name: group_name.clone(),
generation,
counter,
ciphertext,
};
match sender_key.decrypt(&msg) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
// Save updated sender key (counter advanced)
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: format!(
"{} [#{}]",
&sender_fingerprint[..sender_fingerprint.len().min(12)],
group_name
),
text,
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
Err(e) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[group #{}] decrypt failed from {}: {}",
group_name,
&sender_fingerprint[..sender_fingerprint.len().min(12)],
e
),
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
}
}
_ => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[group #{}] no sender key for {} — key distribution needed",
group_name,
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
}
}
WireMessage::SenderKeyDistribution {
sender_fingerprint,
group_name,
chain_key,
generation,
} => {
let dist = warzone_protocol::sender_keys::SenderKeyDistribution {
sender_fingerprint: sender_fingerprint.clone(),
group_name: group_name.clone(),
chain_key,
generation,
};
let sender_key = dist.into_sender_key();
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Received sender key from {} for #{}",
&sender_fingerprint[..sender_fingerprint.len().min(12)],
group_name
),
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
}
WireMessage::CallSignal {
id: _,
sender_fingerprint,
signal_type,
payload: _,
target: _,
} => {
use warzone_protocol::message::CallSignalType;
let sender_short = { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) };
match signal_type {
CallSignalType::Offer => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("\u{1f4de} Incoming call from {} \u{2014} /accept or /reject", sender_short),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
// Terminal bell for incoming call
print!("\x07");
}
CallSignalType::Answer => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("\u{2713} {} accepted the call", sender_short),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Hangup => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Call ended".into(),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Reject => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("{} rejected the call", sender_short),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Ringing => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Ringing...".into(),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
CallSignalType::Busy => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("{} is busy", sender_short),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
_ => {
messages.lock().unwrap().push(ChatLine {
sender: sender_short,
text: format!("\u{1f4de} Call signal: {:?}", signal_type),
is_system: false,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
}
}
}
}
}
/// Real-time message loop via WebSocket (falls back to HTTP polling).
pub async fn poll_loop(
messages: Arc<Mutex<Vec<ChatLine>>>,
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: String,
identity: IdentityKeyPair,
db: Arc<LocalDb>,
client: ServerClient,
last_dm_peer: Arc<Mutex<Option<String>>>,
connected: Arc<AtomicBool>,
) {
let fp = normfp(&our_fp);
let eth_cache: EthCache = Arc::new(std::sync::Mutex::new(HashMap::new()));
// Pre-populate ETH cache for known contacts
prefill_eth_cache(&db, &client, &eth_cache).await;
// Try WebSocket first
let ws_url = client.base_url
.replace("http://", "ws://")
.replace("https://", "wss://");
let ws_url = format!("{}/v1/ws/{}", ws_url, fp);
loop {
match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws_stream, _)) => {
connected.store(true, Ordering::Relaxed);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Real-time connection established".into(),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
use futures_util::StreamExt;
let (_, mut read) = ws_stream.split();
while let Some(Ok(msg)) = read.next().await {
match msg {
tokio_tungstenite::tungstenite::Message::Binary(data) => {
process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &eth_cache, &last_dm_peer);
}
tokio_tungstenite::tungstenite::Message::Text(text) => {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
if json.get("type").and_then(|v| v.as_str()) == Some("missed_call") {
let data = json.get("data").cloned().unwrap_or_default();
let caller = data.get("caller_fp").and_then(|v| v.as_str()).unwrap_or("unknown");
let ts = data.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
let when = chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.with_timezone(&Local).format("%H:%M").to_string())
.unwrap_or_else(|| "?".to_string());
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("\u{1f4de} Missed call from {} at {}", &caller[..caller.len().min(12)], when),
is_system: true,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
print!("\x07");
} else if json.get("type").and_then(|v| v.as_str()) == Some("bot_message") {
let from = json.get("from_name").or(json.get("from")).and_then(|v| v.as_str()).unwrap_or("bot");
let text_content = json.get("text").and_then(|v| v.as_str()).unwrap_or("");
messages.lock().unwrap().push(ChatLine {
sender: format!("@{}", from),
text: text_content.to_string(),
is_system: false,
is_self: false,
message_id: None,
sender_fp: None,
timestamp: Local::now(),
});
print!("\x07");
}
}
}
_ => {}
}
}
connected.store(false, Ordering::Relaxed);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Connection lost, reconnecting...".into(),
is_system: true,
is_self: false,
message_id: None, sender_fp: None, timestamp: Local::now(),
});
tokio::time::sleep(Duration::from_secs(3)).await;
}
Err(_) => {
connected.store(false, Ordering::Relaxed);
// Fallback to HTTP polling
tokio::time::sleep(Duration::from_secs(2)).await;
let raw_msgs = match client.poll_messages(&our_fp).await {
Ok(m) => m,
Err(_) => continue,
};
for raw in &raw_msgs {
process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &eth_cache, &last_dm_peer);
}
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
// Mule protocol implementation — Phase 4.

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

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

View 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);
}
}

View 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),
}

View 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);
}
}

View File

@@ -0,0 +1,113 @@
//! Encrypted friend list — stored on server as opaque blob.
use serde::{Deserialize, Serialize};
use crate::crypto::{aead_encrypt, aead_decrypt, hkdf_derive};
/// A friend entry.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Friend {
/// ETH address or fingerprint
pub address: String,
/// Optional display name / alias
pub alias: Option<String>,
/// When this friend was added (unix timestamp)
pub added_at: i64,
}
/// The full friend list (plaintext, before encryption).
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct FriendList {
pub friends: Vec<Friend>,
}
impl FriendList {
pub fn new() -> Self {
FriendList { friends: vec![] }
}
pub fn add(&mut self, address: &str, alias: Option<&str>) {
// Don't add duplicates
if self.friends.iter().any(|f| f.address == address) {
return;
}
self.friends.push(Friend {
address: address.to_string(),
alias: alias.map(String::from),
added_at: chrono::Utc::now().timestamp(),
});
}
pub fn remove(&mut self, address: &str) {
self.friends.retain(|f| f.address != address);
}
/// Encrypt the friend list for server storage.
/// Key is derived from the user's seed: HKDF(seed, info="warzone-friends").
pub fn encrypt(&self, seed: &[u8; 32]) -> Vec<u8> {
let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32);
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
let plaintext = serde_json::to_vec(self).unwrap_or_default();
aead_encrypt(&key, &plaintext, b"warzone-friends-aad")
}
/// Decrypt a friend list blob from the server.
pub fn decrypt(seed: &[u8; 32], ciphertext: &[u8]) -> Result<Self, crate::errors::ProtocolError> {
let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32);
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
let plaintext = aead_decrypt(&key, ciphertext, b"warzone-friends-aad")?;
serde_json::from_slice(&plaintext)
.map_err(|e| crate::errors::ProtocolError::RatchetError(format!("friend list json: {}", e)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_roundtrip() {
let seed = [42u8; 32];
let mut list = FriendList::new();
list.add("0x1234abcd", Some("alice"));
list.add("0xdeadbeef", None);
let encrypted = list.encrypt(&seed);
let decrypted = FriendList::decrypt(&seed, &encrypted).unwrap();
assert_eq!(decrypted.friends.len(), 2);
assert_eq!(decrypted.friends[0].address, "0x1234abcd");
assert_eq!(decrypted.friends[0].alias.as_deref(), Some("alice"));
assert_eq!(decrypted.friends[1].address, "0xdeadbeef");
}
#[test]
fn wrong_seed_fails() {
let seed = [42u8; 32];
let wrong_seed = [99u8; 32];
let mut list = FriendList::new();
list.add("0x1234", None);
let encrypted = list.encrypt(&seed);
assert!(FriendList::decrypt(&wrong_seed, &encrypted).is_err());
}
#[test]
fn no_duplicate_add() {
let mut list = FriendList::new();
list.add("0x1234", None);
list.add("0x1234", Some("alice"));
assert_eq!(list.friends.len(), 1);
}
#[test]
fn remove_works() {
let mut list = FriendList::new();
list.add("0x1234", None);
list.add("0x5678", None);
list.remove("0x1234");
assert_eq!(list.friends.len(), 1);
assert_eq!(list.friends[0].address, "0x5678");
}
}

View File

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

View 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);
}
}

View 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;

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

View 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);
}
}

View 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);
}
}

View 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(&current_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());
}
}
}

View 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());
}
}

View 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,
}

View 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>;
}

View 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())
}
}

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

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

View File

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

View File

@@ -0,0 +1,282 @@
//! Built-in BotFather: processes messages to @botfather and manages bot lifecycle.
//!
//! Supports: /start, /newbot, /mybots, /deletebot, /help
//! Runs as a server-side handler — no external process needed.
use crate::state::AppState;
const BOTFATHER_FP: &str = "00000000000000000b0ffa00e000000f";
/// Check if a message is destined for BotFather and handle it.
/// Called from deliver_or_queue when the recipient is the BotFather fingerprint.
/// Returns true if handled (message consumed).
pub async fn handle_botfather_message(state: &AppState, from_fp: &str, message: &[u8]) -> bool {
if !state.bots_enabled {
return false;
}
// Try to parse as plaintext bot_message JSON
let bot_msg: serde_json::Value = match serde_json::from_slice(message) {
Ok(v) => v,
Err(_) => return false, // Encrypted messages can't be processed by built-in handler
};
if bot_msg.get("type").and_then(|v| v.as_str()) != Some("bot_message") {
return false;
}
let text = bot_msg
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let from_name = bot_msg
.get("from_name")
.and_then(|v| v.as_str())
.unwrap_or(from_fp);
tracing::info!(
"BotFather: message from {} ({}): {}",
from_fp,
from_name,
text
);
let response = match text {
"/start" | "/help" => {
"Welcome to BotFather! I can help you create and manage bots.\n\n\
Commands:\n\
/newbot - Create a new bot\n\
/mybots - List your bots\n\
/deletebot <name> - Delete a bot\n\
/token <name> - Get bot token\n\
/help - Show this message"
.to_string()
}
t if t.starts_with("/newbot") => handle_newbot(state, from_fp, t).await,
t if t.starts_with("/deletebot") => handle_deletebot(state, from_fp, t).await,
"/mybots" => handle_mybots(state, from_fp).await,
t if t.starts_with("/token") => handle_token(state, from_fp, t).await,
_ => "I don't understand that command. Try /help".to_string(),
};
// Send response back to the user
send_botfather_reply(state, from_fp, &response).await;
true
}
async fn handle_newbot(state: &AppState, owner_fp: &str, text: &str) -> String {
// Parse: /newbot <name>
let name = text.strip_prefix("/newbot").unwrap_or("").trim();
if name.is_empty() {
return "Usage: /newbot <botname>\n\nExample: /newbot WeatherBot\n\n\
The name must end with 'bot' or 'Bot'."
.to_string();
}
// Validate name
if name.len() > 32 || name.len() < 3 {
return "Bot name must be 3-32 characters.".to_string();
}
let name_lower = name.to_lowercase();
if !name_lower.ends_with("bot") {
return "Bot name must end with 'bot' or 'Bot'. Example: WeatherBot, my_bot".to_string();
}
// Check if alias is taken
let alias_key = format!("a:{}", name_lower);
if state
.db
.aliases
.get(alias_key.as_bytes())
.ok()
.flatten()
.is_some()
{
return format!(
"Sorry, @{} is already taken. Try a different name.",
name_lower
);
}
// Generate fingerprint and token
let fp_bytes: [u8; 16] = rand::random();
let fp = hex::encode(fp_bytes);
let token_rand: [u8; 16] = rand::random();
let token = format!("{}:{}", &fp[..16], hex::encode(token_rand));
// Store bot info
let bot_info = serde_json::json!({
"name": name,
"fingerprint": fp,
"token": token,
"owner": owner_fp,
"e2e": false,
"created_at": chrono::Utc::now().timestamp(),
});
let bot_key = format!("bot:{}", token);
let _ = state.db.tokens.insert(
bot_key.as_bytes(),
serde_json::to_vec(&bot_info).unwrap_or_default(),
);
let fp_key = format!("bot_fp:{}", fp);
let _ = state.db.tokens.insert(fp_key.as_bytes(), token.as_bytes());
// Register alias (all 3 keys needed for resolve_alias to work)
let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes());
let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes());
let alias_record = serde_json::json!({
"alias": name_lower,
"fingerprint": fp,
"recovery_key": "",
"registered_at": chrono::Utc::now().timestamp(),
"last_active": chrono::Utc::now().timestamp(),
});
let _ = state.db.aliases.insert(format!("rec:{}", name_lower).as_bytes(), serde_json::to_vec(&alias_record).unwrap_or_default());
tracing::info!(
"BotFather: created bot @{} for owner {}",
name_lower,
owner_fp
);
format!(
"Done! Your new bot @{} is ready.\n\n\
Token: {}\n\n\
Keep this token secret! Use it to access the Bot API.\n\n\
API endpoint: /v1/bot/{}/getUpdates",
name_lower, token, token
)
}
async fn handle_deletebot(state: &AppState, owner_fp: &str, text: &str) -> String {
let name = text
.strip_prefix("/deletebot")
.unwrap_or("")
.trim()
.to_lowercase();
if name.is_empty() {
return "Usage: /deletebot <botname>".to_string();
}
// Find the bot
let alias_key = format!("a:{}", name);
let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() {
Some(v) => String::from_utf8_lossy(&v).to_string(),
None => return format!("Bot @{} not found.", name),
};
// Get bot info to verify ownership
let token_key = format!("bot_fp:{}", bot_fp);
let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() {
Some(v) => String::from_utf8_lossy(&v).to_string(),
None => return format!("Bot @{} not found in registry.", name),
};
let bot_key = format!("bot:{}", token);
if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() {
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
if owner != owner_fp && owner != "system" {
return format!("You don't own @{}. Only the owner can delete it.", name);
}
}
}
// Delete everything
let _ = state.db.tokens.remove(bot_key.as_bytes());
let _ = state.db.tokens.remove(token_key.as_bytes());
let _ = state.db.aliases.remove(alias_key.as_bytes());
let _ = state
.db
.aliases
.remove(format!("fp:{}", bot_fp).as_bytes());
let _ = state.db.keys.remove(bot_fp.as_bytes());
tracing::info!("BotFather: deleted bot @{} by owner {}", name, owner_fp);
format!("Bot @{} has been deleted.", name)
}
async fn handle_mybots(state: &AppState, owner_fp: &str) -> String {
let mut bots = Vec::new();
for item in state.db.tokens.iter().flatten() {
let key = String::from_utf8_lossy(&item.0).to_string();
if !key.starts_with("bot:") {
continue;
}
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&item.1) {
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
if owner == owner_fp {
let name = info.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let e2e = info.get("e2e").and_then(|v| v.as_bool()).unwrap_or(false);
let mode = if e2e { "E2E" } else { "plaintext" };
bots.push(format!(" @{} ({})", name.to_lowercase(), mode));
}
}
}
if bots.is_empty() {
"You have no bots. Use /newbot <name> to create one.".to_string()
} else {
format!("Your bots ({}):\n{}", bots.len(), bots.join("\n"))
}
}
async fn handle_token(state: &AppState, owner_fp: &str, text: &str) -> String {
let name = text
.strip_prefix("/token")
.unwrap_or("")
.trim()
.to_lowercase();
if name.is_empty() {
return "Usage: /token <botname>".to_string();
}
let alias_key = format!("a:{}", name);
let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() {
Some(v) => String::from_utf8_lossy(&v).to_string(),
None => return format!("Bot @{} not found.", name),
};
let token_key = format!("bot_fp:{}", bot_fp);
let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() {
Some(v) => String::from_utf8_lossy(&v).to_string(),
None => return format!("Token not found for @{}.", name),
};
// Verify ownership
let bot_key = format!("bot:{}", token);
if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() {
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
if owner != owner_fp {
return format!("You don't own @{}.", name);
}
}
}
format!("Token for @{}:\n{}", name, token)
}
/// Send a reply from BotFather to a user.
async fn send_botfather_reply(state: &AppState, to_fp: &str, text: &str) {
let msg = serde_json::json!({
"type": "bot_message",
"id": uuid::Uuid::new_v4().to_string(),
"from": BOTFATHER_FP,
"from_name": "BotFather",
"text": text,
"timestamp": chrono::Utc::now().timestamp(),
});
let msg_bytes = serde_json::to_vec(&msg).unwrap_or_default();
// Deliver directly (don't go through deliver_or_queue to avoid recursion)
if !state.push_to_client(to_fp, &msg_bytes).await {
// Queue for offline pickup
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
let _ = state.db.messages.insert(key.as_bytes(), msg_bytes);
}
}

View File

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

View 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,
})
}
}

View 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>;

View File

@@ -0,0 +1,340 @@
//! Federation: two-server message relay via persistent WebSocket.
//!
//! Each server maintains a WS connection to its peer. Presence updates
//! and message forwards flow over this single connection. Reconnects
//! automatically on failure.
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
/// Federation configuration loaded from JSON.
#[derive(Clone, Debug, serde::Deserialize)]
pub struct FederationConfig {
pub server_id: String,
pub shared_secret: String,
pub peer: PeerConfig,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct PeerConfig {
pub id: String,
pub url: String,
}
/// Load federation config from a JSON file.
pub fn load_config(path: &str) -> anyhow::Result<FederationConfig> {
let data = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("failed to read federation config '{}': {}", path, e))?;
let config: FederationConfig = serde_json::from_str(&data)
.map_err(|e| anyhow::anyhow!("invalid federation config: {}", e))?;
Ok(config)
}
/// Remote presence: which fingerprints are on the peer server.
#[derive(Clone, Debug)]
pub struct RemotePresence {
pub peer_id: String,
pub fingerprints: HashSet<String>,
pub last_updated: i64,
pub connected: bool,
}
impl RemotePresence {
pub fn new(peer_id: String) -> Self {
RemotePresence {
peer_id,
fingerprints: HashSet::new(),
last_updated: 0,
connected: false,
}
}
pub fn contains(&self, fp: &str) -> bool {
self.connected && self.fingerprints.contains(fp)
}
}
/// Sender for outgoing federation messages over the WS.
pub type FederationSender = Arc<Mutex<Option<tokio::sync::mpsc::UnboundedSender<String>>>>;
/// Handle for communicating with the federation peer.
#[derive(Clone)]
pub struct FederationHandle {
pub config: FederationConfig,
pub remote_presence: Arc<Mutex<RemotePresence>>,
/// Channel to send messages over the outgoing WS to the peer.
pub outgoing: FederationSender,
/// HTTP client for one-shot requests (key fetch, etc.)
pub client: reqwest::Client,
}
impl FederationHandle {
pub fn new(config: FederationConfig) -> Self {
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
config.peer.id.clone(),
)));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.expect("failed to build HTTP client");
FederationHandle {
config,
remote_presence,
outgoing: Arc::new(Mutex::new(None)),
client,
}
}
/// Check if a fingerprint is known to be on the peer server.
pub async fn is_remote(&self, fp: &str) -> bool {
let rp = self.remote_presence.lock().await;
rp.contains(fp)
}
/// Forward a message to the peer server via the persistent WS.
pub async fn forward_message(&self, to_fp: &str, message: &[u8]) -> bool {
let msg = serde_json::json!({
"type": "forward",
"to": to_fp,
"message": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, message),
"from_server": self.config.server_id,
});
self.send_json(msg).await
}
/// Fetch a pre-key bundle from the peer server (HTTP GET fallback).
/// Used when a local key lookup fails and the fingerprint is on the remote.
pub async fn fetch_remote_bundle(&self, fingerprint: &str) -> Option<Vec<u8>> {
let url = format!("{}/v1/keys/{}", self.config.peer.url, fingerprint);
let resp = self.client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let data: serde_json::Value = resp.json().await.ok()?;
let bundle_b64 = data.get("bundle")?.as_str()?;
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bundle_b64).ok()
}
/// Resolve an alias on the peer server.
/// Returns Some(fingerprint) if the peer knows this alias.
pub async fn resolve_remote_alias(&self, alias: &str) -> Option<String> {
let url = format!("{}/v1/alias/resolve/{}", self.config.peer.url, alias);
let resp = self.client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let data: serde_json::Value = resp.json().await.ok()?;
// Check for error (alias not found on peer)
if data.get("error").is_some() {
return None;
}
data.get("fingerprint").and_then(|v| v.as_str()).map(String::from)
}
/// Check if an alias is already taken on the peer server.
/// Returns true if the alias exists on the peer (taken).
pub async fn is_alias_taken_remote(&self, alias: &str) -> bool {
self.resolve_remote_alias(alias).await.is_some()
}
/// Push local presence to peer via the persistent WS.
pub async fn push_presence(&self, fingerprints: Vec<String>) -> bool {
let msg = serde_json::json!({
"type": "presence",
"server_id": self.config.server_id,
"fingerprints": fingerprints,
});
self.send_json(msg).await
}
/// Send a JSON message over the outgoing WS channel.
async fn send_json(&self, msg: serde_json::Value) -> bool {
let guard = self.outgoing.lock().await;
if let Some(ref tx) = *guard {
let json_str = serde_json::to_string(&msg).unwrap_or_default();
tx.send(json_str).is_ok()
} else {
false
}
}
}
/// Background task: connect to peer's WS endpoint, send auth, then loop.
/// Handles reconnection on failure.
pub async fn outgoing_ws_loop(
handle: FederationHandle,
state: crate::state::AppState,
) {
let ws_url = handle.config.peer.url
.replace("http://", "ws://")
.replace("https://", "wss://");
let ws_url = format!("{}/v1/federation/ws", ws_url);
loop {
tracing::info!("Federation: connecting to peer {} at {}", handle.config.peer.id, ws_url);
match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws_stream, _)) => {
tracing::info!("Federation: connected to peer {}", handle.config.peer.id);
use futures_util::{SinkExt, StreamExt};
let (mut ws_tx, mut ws_rx) = ws_stream.split();
// Send auth as first message
let auth_msg = serde_json::json!({
"type": "auth",
"secret": handle.config.shared_secret,
"server_id": handle.config.server_id,
});
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(
serde_json::to_string(&auth_msg).unwrap_or_default()
)).await.is_err() {
tracing::warn!("Federation: failed to send auth to peer");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
continue;
}
// Set up outgoing channel
let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
{
let mut guard = handle.outgoing.lock().await;
*guard = Some(out_tx);
}
{
let mut rp = handle.remote_presence.lock().await;
rp.connected = true;
}
// Send initial presence
let fps: Vec<String> = {
let conns = state.connections.lock().await;
conns.keys().cloned().collect()
};
let _ = handle.push_presence(fps).await;
// Spawn task to forward outgoing channel + periodic ping to WS
let send_task = tokio::spawn(async move {
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
loop {
tokio::select! {
msg = out_rx.recv() => {
match msg {
Some(text) => {
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(text)).await.is_err() {
break;
}
}
None => break,
}
}
_ = ping_interval.tick() => {
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.is_err() {
break;
}
}
}
}
});
// Spawn task to periodically re-push presence
let presence_handle = handle.clone();
let presence_conns = state.connections.clone();
let presence_task = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
loop {
interval.tick().await;
let fps: Vec<String> = {
let conns = presence_conns.lock().await;
conns.keys().cloned().collect()
};
if !presence_handle.push_presence(fps).await {
break;
}
}
});
// Read incoming messages from peer
while let Some(Ok(msg)) = ws_rx.next().await {
match msg {
tokio_tungstenite::tungstenite::Message::Text(text) => {
handle_incoming_federation_msg(&text, &handle, &state).await;
}
tokio_tungstenite::tungstenite::Message::Pong(_) => {} // keepalive response
tokio_tungstenite::tungstenite::Message::Close(_) => break,
_ => {}
}
}
// Connection lost
send_task.abort();
presence_task.abort();
{
let mut guard = handle.outgoing.lock().await;
*guard = None;
}
{
let mut rp = handle.remote_presence.lock().await;
rp.connected = false;
rp.fingerprints.clear();
}
tracing::warn!("Federation: lost connection to peer {}, reconnecting...", handle.config.peer.id);
}
Err(e) => {
tracing::warn!("Federation: failed to connect to peer {}: {}", handle.config.peer.id, e);
}
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
/// Process a single incoming JSON message from the federated peer WS.
async fn handle_incoming_federation_msg(
text: &str,
handle: &FederationHandle,
state: &crate::state::AppState,
) {
let parsed: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => return,
};
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
match msg_type {
"presence" => {
let fingerprints: Vec<String> = parsed.get("fingerprints")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("?");
let count = fingerprints.len();
let mut rp = handle.remote_presence.lock().await;
rp.fingerprints = fingerprints.into_iter().collect();
rp.last_updated = chrono::Utc::now().timestamp();
tracing::debug!("Federation: received {} fingerprints from {}", count, server_id);
}
"forward" => {
let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or("");
let message_b64 = parsed.get("message").and_then(|v| v.as_str()).unwrap_or("");
let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("?");
if let Ok(message) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) {
let delivered = state.push_to_client(to, &message).await;
if !delivered {
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
let _ = state.db.messages.insert(key.as_bytes(), message.as_slice());
tracing::info!("Federation: queued message from {} for offline {}", from_server, to);
} else {
tracing::debug!("Federation: delivered message from {} to {}", from_server, to);
}
}
}
_ => {
tracing::debug!("Federation: unknown message type '{}'", msg_type);
}
}
}

View File

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

View 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(())
}

View 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" })))
}
}

View 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 }))
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
//! Federation route handlers: WS endpoint for peer servers + status.
use axum::{
extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}},
response::IntoResponse,
routing::get,
Json, Router,
};
use futures_util::{SinkExt, StreamExt};
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/federation/ws", get(federation_ws_handler))
.route("/federation/status", get(federation_status))
}
/// WebSocket endpoint for incoming peer server connections.
async fn federation_ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_peer_ws(socket, state))
}
/// Handle an incoming federation WS connection from the peer server.
async fn handle_peer_ws(socket: WebSocket, state: AppState) {
let (mut ws_tx, mut ws_rx) = socket.split();
// First message must be auth
let secret = match state.federation {
Some(ref f) => f.config.shared_secret.clone(),
None => {
tracing::warn!("Federation: WS connection rejected -- federation not configured");
return;
}
};
// Wait for auth message (5 second timeout)
let auth_msg = tokio::time::timeout(
std::time::Duration::from_secs(5),
ws_rx.next(),
).await;
let peer_id = match auth_msg {
Ok(Some(Ok(Message::Text(text)))) => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
let msg_secret = parsed.get("secret").and_then(|v| v.as_str()).unwrap_or("");
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown");
if msg_type != "auth" || msg_secret != secret {
tracing::warn!("Federation: WS auth failed from {}", server_id);
return;
}
tracing::info!("Federation: peer {} authenticated via WS", server_id);
server_id.to_string()
} else {
tracing::warn!("Federation: invalid auth JSON");
return;
}
}
_ => {
tracing::warn!("Federation: no auth message received within timeout");
return;
}
};
// Process incoming messages from the authenticated peer
while let Some(Ok(msg)) = ws_rx.next().await {
if let Message::Text(text) = msg {
let parsed: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => continue,
};
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
match msg_type {
"presence" => {
let fingerprints: Vec<String> = parsed.get("fingerprints")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let count = fingerprints.len();
if let Some(ref federation) = state.federation {
let mut rp = federation.remote_presence.lock().await;
rp.fingerprints = fingerprints.into_iter().collect();
rp.last_updated = chrono::Utc::now().timestamp();
rp.connected = true;
}
tracing::debug!("Federation WS: {} announced {} fingerprints", peer_id, count);
// Send our presence back
if let Some(ref federation) = state.federation {
let fps: Vec<String> = {
let conns = state.connections.lock().await;
conns.keys().cloned().collect()
};
let reply = serde_json::json!({
"type": "presence",
"server_id": federation.config.server_id,
"fingerprints": fps,
});
let _ = ws_tx.send(Message::Text(serde_json::to_string(&reply).unwrap_or_default())).await;
}
}
"forward" => {
let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or("");
let message_b64 = parsed.get("message").and_then(|v| v.as_str()).unwrap_or("");
let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("?");
if let Ok(message) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) {
let delivered = state.push_to_client(to, &message).await;
if !delivered {
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
let _ = state.db.messages.insert(key.as_bytes(), message.as_slice());
tracing::info!("Federation WS: queued from {} for offline {}", from_server, to);
} else {
tracing::debug!("Federation WS: delivered from {} to {}", from_server, to);
}
}
}
_ => {}
}
}
}
// Peer disconnected
if let Some(ref federation) = state.federation {
let mut rp = federation.remote_presence.lock().await;
rp.connected = false;
rp.fingerprints.clear();
}
tracing::info!("Federation WS: peer {} disconnected", peer_id);
}
/// Federation health status.
async fn federation_status(
State(state): State<AppState>,
) -> Json<serde_json::Value> {
match state.federation {
Some(ref federation) => {
let rp = federation.remote_presence.lock().await;
Json(serde_json::json!({
"enabled": true,
"server_id": federation.config.server_id,
"peer_id": federation.config.peer.id,
"peer_url": federation.config.peer.url,
"peer_connected": rp.connected,
"remote_clients": rp.fingerprints.len(),
"last_sync": rp.last_updated,
}))
}
None => {
Json(serde_json::json!({ "enabled": false }))
}
}
}

View File

@@ -0,0 +1,54 @@
use axum::{
extract::State,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use crate::auth_middleware::AuthFingerprint;
use crate::errors::AppResult;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/friends", get(get_friends))
.route("/friends", post(save_friends))
}
/// Get the encrypted friend list blob for the authenticated user.
async fn get_friends(
auth: AuthFingerprint,
State(state): State<AppState>,
) -> AppResult<Json<serde_json::Value>> {
match state.db.friends.get(auth.fingerprint.as_bytes())? {
Some(data) => {
let blob = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
Ok(Json(serde_json::json!({
"fingerprint": auth.fingerprint,
"data": blob,
})))
}
None => Ok(Json(serde_json::json!({
"fingerprint": auth.fingerprint,
"data": null,
}))),
}
}
#[derive(Deserialize)]
struct SaveFriendsRequest {
data: String, // base64-encoded encrypted blob
}
/// Save the encrypted friend list blob.
async fn save_friends(
auth: AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<SaveFriendsRequest>,
) -> AppResult<Json<serde_json::Value>> {
let blob = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &req.data)
.map_err(|e| anyhow::anyhow!("invalid base64: {}", e))?;
state.db.friends.insert(auth.fingerprint.as_bytes(), blob)?;
tracing::info!("Saved friend list for {} ({} bytes)", auth.fingerprint, req.data.len());
Ok(Json(serde_json::json!({ "ok": true })))
}

View File

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

View 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)
}

View 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() }))
}

View 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 })))
}

View 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()
}

View File

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

View File

@@ -0,0 +1,136 @@
use axum::{
extract::{Path, State},
routing::get,
Json, Router,
};
use crate::errors::AppResult;
use crate::state::AppState;
/// Convert a fingerprint to a per-bot unique numeric ID.
/// Hash(bot_token + user_fp) → i64. Different bots see different IDs for the same user.
/// This prevents cross-bot user correlation (same privacy model as Telegram).
pub fn fp_to_numeric_id_for_bot(fp: &str, bot_token: &str) -> i64 {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(bot_token.as_bytes());
hasher.update(b":");
hasher.update(fp.as_bytes());
let hash = hasher.finalize();
let mut arr = [0u8; 8];
arr.copy_from_slice(&hash[..8]);
i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive
}
/// Convert a fingerprint hex string to a stable i64 ID (non-bot contexts).
/// Uses first 8 bytes of the fingerprint as a positive i64.
pub fn fp_to_numeric_id(fp: &str) -> i64 {
let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).take(16).collect();
let bytes = hex::decode(&clean).unwrap_or_default();
if bytes.len() >= 8 {
let mut arr = [0u8; 8];
arr.copy_from_slice(&bytes[..8]);
i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive
} else {
0
}
}
pub fn routes() -> Router<AppState> {
Router::new().route("/resolve/:address", get(resolve_address))
}
/// Resolve an address to a fingerprint.
///
/// Accepts: ETH address (`0x...`), alias (`@name`), or raw fingerprint.
async fn resolve_address(
State(state): State<AppState>,
Path(address): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let addr = address.trim().to_lowercase();
// ETH address: 0x...
if addr.starts_with("0x") {
if let Some(fp_bytes) = state.db.eth_addresses.get(addr.as_bytes())? {
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "eth",
})));
}
// Try federation
if let Some(ref federation) = state.federation {
let url = format!("{}/v1/resolve/{}", federation.config.peer.url, addr);
if let Ok(resp) = federation.client.get(&url).send().await {
if resp.status().is_success() {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(fp),
"type": "eth",
"federated": true,
})));
}
}
}
}
}
return Ok(Json(serde_json::json!({ "error": "address not found" })));
}
// Alias: @name
if addr.starts_with('@') {
let alias = &addr[1..];
// Try local alias resolution
let alias_key = format!("a:{}", alias);
if let Some(fp_bytes) = state.db.aliases.get(alias_key.as_bytes())? {
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "alias",
})));
}
// Try federation
if let Some(ref federation) = state.federation {
if let Some(fp) = federation.resolve_remote_alias(alias).await {
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"type": "alias",
"federated": true,
})));
}
}
return Ok(Json(serde_json::json!({ "error": "alias not found" })));
}
// Raw fingerprint: just echo back with optional reverse ETH lookup
let fp = addr
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>();
if fp.len() == 32 {
let rev_key = format!("rev:{}", fp);
let eth = state
.db
.eth_addresses
.get(rev_key.as_bytes())?
.map(|v| String::from_utf8_lossy(&v).to_string());
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"numeric_id": fp_to_numeric_id(&fp),
"eth_address": eth,
"type": "fingerprint",
})));
}
Ok(Json(serde_json::json!({ "error": "unrecognized address format" })))
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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

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

View 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)

View 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
}

View 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
}

View 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

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

View 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

View 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

View 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

View 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 ==="

View 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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
# /etc/systemd/journald.conf.d/warzone.conf
# Cap journal storage to avoid filling disk on mequ
[Journal]
SystemMaxUse=50M
SystemMaxFileSize=10M
MaxRetentionSec=7day

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

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
# Setup script — run as root on each server.
# Usage: ./setup.sh <mequ|kh3rad3ree>
HOSTNAME="${1:-}"
if [ -z "$HOSTNAME" ] || { [ "$HOSTNAME" != "mequ" ] && [ "$HOSTNAME" != "kh3rad3ree" ]; }; then
echo "Usage: $0 <mequ|kh3rad3ree>"
exit 1
fi
echo "=== Setting up featherChat on $HOSTNAME ==="
# Create warzone user if it doesn't exist
if ! id warzone &>/dev/null; then
echo "[1/4] Creating warzone user..."
useradd -r -m -s /bin/bash warzone
else
echo "[1/4] User warzone already exists"
fi
# Create data directory
echo "[2/4] Creating directories..."
mkdir -p /home/warzone/data
chown -R warzone:warzone /home/warzone
# Copy binaries
echo "[3/4] Installing binaries..."
cp warzone-server warzone-client /home/warzone/
chmod +x /home/warzone/warzone-server /home/warzone/warzone-client
cp "federation-${HOSTNAME}.json" /home/warzone/federation.json
chown warzone:warzone /home/warzone/warzone-server /home/warzone/warzone-client /home/warzone/federation.json
# Copy environment file
if [ -f "warzone-server.env.${HOSTNAME}" ]; then
cp "warzone-server.env.${HOSTNAME}" /home/warzone/server.env
chown warzone:warzone /home/warzone/server.env
echo " Environment: $(cat /home/warzone/server.env | grep -v '^#' | grep .)"
fi
# Install systemd service + journald log cap
echo "[4/5] Installing systemd service..."
cp warzone-server.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable warzone-server
echo "[5/5] Capping journal logs (50MB max, 7 day retention)..."
mkdir -p /etc/systemd/journald.conf.d
cp journald-warzone.conf /etc/systemd/journald.conf.d/warzone.conf
systemctl restart systemd-journald
# Vacuum existing logs
journalctl --vacuum-size=50M 2>/dev/null || true
echo ""
echo "=== Done ==="
echo "Start: systemctl start warzone-server"
echo "Status: systemctl status warzone-server"
echo "Logs: journalctl -u warzone-server -f"
echo "Stop: systemctl stop warzone-server"

View File

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

Some files were not shown because too many files have changed in this diff Show More