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