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>
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>
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>
- 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>
/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>
/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>
<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>
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>
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>
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>
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>
- 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>
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>
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>
- 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>
- 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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
- Key transparency via DNS TXT records with self-signatures
(server can't MITM because it can't forge user's signature)
- Per-device ratchet sessions (Signal model), cross-device sync via seed
- LoRa deferred to later phases, not Phase 1
- Sealed sender before onion routing
- Phase 3 updated to include key transparency alongside federation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Decisions: Sender Keys for groups, optional onion routing, deniability
by default, Bluetooth + LoRa transports, no tokenization.
New sections: transport abstraction (HTTPS/WS/BT/LoRa/Wi-Fi Direct/USB),
LoRa compact binary format, sealed sender vs onion routing discussion.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- /reply <msg> or /r <msg> sends encrypted DM to last person
- lastDmPeer set when sending a DM or receiving one
- Shows error if no prior DM conversation exists
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Browser:
- ECDH key pair saved to localStorage (chat-key-priv, chat-key-pub)
- Loaded on reconnect, only generated once
- Re-registers public key with server on every connect
- Corrupted keys auto-regenerate
Server:
- Keys saved to keys.json on disk after each registration
- Loaded on startup, survives restarts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server:
- /keys POST: register ECDH public key (JWK) for a username
- /keys GET: list users with registered keys
- /keys/<user> GET: get user's public key
- /dm POST: relay encrypted DM blob to recipient
- SSE streams now register for DM delivery via name param
- Server never sees plaintext - only ciphertext passes through
Web UI:
- Auto-generates ECDH P-256 key pair on load (no setup needed)
- /dm @username message - sends E2E encrypted DM
- /users - list users with registered keys
- DMs shown with lock icon, pink color, direction arrows
- Decryption happens entirely in browser
- Key re-registered on name change
- Derived AES keys cached per peer
Protocol:
- ECDH key exchange: each client exports JWK public key
- Shared secret derived via ECDH P-256
- Messages encrypted with AES-256-GCM + random 12-byte nonce
- Ciphertext + nonce sent as base64 through server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- /group/<name> URL creates/joins a group (auto-created on first visit)
- / and /chat redirect to /group/lobby (default group)
- Each group has isolated history, clients, and SSE streams
- /setpass <password> sets a password for the current group
- /clearpass removes the password
- Password prompt modal in web UI, stored in sessionStorage
- SSE sends auth-fail event if wrong password, triggers re-prompt
- Group name shown as tag in header
- TCP clients use lobby group by default
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>