diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 57a3c9b..9236200 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -8,6 +8,7 @@ use x25519_dalek::PublicKey; use crate::net::ServerClient; use crate::storage::LocalDb; +use base64::Engine; use chrono::Local; use super::types::{App, ChatLine, ReceiptStatus, normfp}; @@ -48,6 +49,7 @@ impl App { " /help, /? Show this help", " /info Show your fingerprint", " /eth Show Ethereum address", + " /seed Show recovery mnemonic (24 words)", " /peer , /p Set DM peer by fingerprint", " /peer @alias Set DM peer by alias", " /reply, /r Reply to last DM sender", @@ -57,6 +59,9 @@ impl App { " /alias Register an alias for yourself", " /aliases List all registered aliases", " /unalias Remove your alias", + " /friend List friends with online status", + " /friend
Add a friend", + " /unfriend
Remove a friend", " /devices List your active device sessions", " /kick Kick a specific device session", " /g Switch to group (auto-join)", @@ -173,6 +178,127 @@ impl App { } return; } + if text == "/seed" { + if let Ok(seed) = crate::keystore::load_seed_raw() { + let mnemonic = warzone_protocol::identity::Seed::from_bytes(seed).to_mnemonic(); + self.add_message(ChatLine { sender: "system".into(), text: "Your recovery seed (keep secret!):".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + self.add_message(ChatLine { sender: "system".into(), text: mnemonic, is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: "Failed to load seed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + return; + } + if text == "/friend" || text == "/friends" { + // Fetch encrypted friend list from server, decrypt locally + let url = format!("{}/v1/friends", client.base_url); + match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + match data.get("data").and_then(|v| v.as_str()) { + Some(blob_b64) => { + if let Ok(seed) = crate::keystore::load_seed_raw() { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + match warzone_protocol::friends::FriendList::decrypt(&seed, &blob) { + Ok(list) => { + if list.friends.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } else { + self.add_message(ChatLine { sender: "system".into(), text: format!("Friends ({}):", list.friends.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + for f in &list.friends { + // Check presence + let presence_url = format!("{}/v1/presence/{}", client.base_url, normfp(&f.address)); + let online = match client.client.get(&presence_url).send().await { + Ok(r) => r.json::().await.ok() + .and_then(|d| d.get("online").and_then(|v| v.as_bool())) + .unwrap_or(false), + Err(_) => false, + }; + let status = if online { "online" } else { "offline" }; + let label = match &f.alias { + Some(a) => format!(" @{} ({}) — {}", a, &f.address[..f.address.len().min(16)], status), + None => format!(" {} — {}", &f.address[..f.address.len().min(16)], status), + }; + self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Failed to decrypt friend list: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + } + } + } + _ => { + self.add_message(ChatLine { sender: "system".into(), text: "No friends yet. Use /friend
to add.".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + } + } + } + Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }), + } + return; + } + if text.starts_with("/friend ") { + let addr = text[8..].trim().to_string(); + if addr.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "Usage: /friend
".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + return; + } + if let Ok(seed) = crate::keystore::load_seed_raw() { + // Fetch existing list + let url = format!("{}/v1/friends", client.base_url); + let mut list = match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(blob_b64) = data.get("data").and_then(|v| v.as_str()) { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + warzone_protocol::friends::FriendList::decrypt(&seed, &blob).unwrap_or_default() + } else { + warzone_protocol::friends::FriendList::new() + } + } else { + warzone_protocol::friends::FriendList::new() + } + } + Err(_) => warzone_protocol::friends::FriendList::new(), + }; + list.add(&addr, None); + let encrypted = list.encrypt(&seed); + let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); + let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await; + self.add_message(ChatLine { sender: "system".into(), text: format!("Added {} to friends", addr), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + return; + } + if text.starts_with("/unfriend ") { + let addr = text[10..].trim().to_string(); + if addr.is_empty() { + self.add_message(ChatLine { sender: "system".into(), text: "Usage: /unfriend
".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + return; + } + if let Ok(seed) = crate::keystore::load_seed_raw() { + let url = format!("{}/v1/friends", client.base_url); + let mut list = match client.client.get(&url).send().await { + Ok(resp) => { + if let Ok(data) = resp.json::().await { + if let Some(blob_b64) = data.get("data").and_then(|v| v.as_str()) { + let blob = base64::engine::general_purpose::STANDARD.decode(blob_b64).unwrap_or_default(); + warzone_protocol::friends::FriendList::decrypt(&seed, &blob).unwrap_or_default() + } else { + warzone_protocol::friends::FriendList::new() + } + } else { + warzone_protocol::friends::FriendList::new() + } + } + Err(_) => warzone_protocol::friends::FriendList::new(), + }; + list.remove(&addr); + let encrypted = list.encrypt(&seed); + let blob_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); + let _ = client.client.post(&url).json(&serde_json::json!({"data": blob_b64})).send().await; + self.add_message(ChatLine { sender: "system".into(), text: format!("Removed {} from friends", addr), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + return; + } if text == "/devices" { let url = format!("{}/v1/devices", client.base_url); // Try to get bearer token from a recent auth (for now, make unauthenticated GET) diff --git a/warzone/crates/warzone-protocol/src/friends.rs b/warzone/crates/warzone-protocol/src/friends.rs new file mode 100644 index 0000000..1522f6b --- /dev/null +++ b/warzone/crates/warzone-protocol/src/friends.rs @@ -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, + /// 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, +} + +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 { + 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 { + 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"); + } +} diff --git a/warzone/crates/warzone-protocol/src/lib.rs b/warzone/crates/warzone-protocol/src/lib.rs index 71b8ce2..1cb9654 100644 --- a/warzone/crates/warzone-protocol/src/lib.rs +++ b/warzone/crates/warzone-protocol/src/lib.rs @@ -12,3 +12,4 @@ pub mod store; pub mod history; pub mod sender_keys; pub mod ethereum; +pub mod friends; diff --git a/warzone/crates/warzone-server/src/db.rs b/warzone/crates/warzone-server/src/db.rs index 5cbf538..1db7b4d 100644 --- a/warzone/crates/warzone-server/src/db.rs +++ b/warzone/crates/warzone-server/src/db.rs @@ -8,6 +8,8 @@ pub struct Database { 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, } @@ -21,6 +23,8 @@ impl Database { 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, @@ -29,6 +33,8 @@ impl Database { tokens, calls, missed_calls, + friends, + eth_addresses, _db: db, }) } diff --git a/warzone/crates/warzone-server/src/routes/bot.rs b/warzone/crates/warzone-server/src/routes/bot.rs new file mode 100644 index 0000000..3578986 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/bot.rs @@ -0,0 +1,390 @@ +//! Telegram Bot API compatibility layer. +//! +//! Bots register with a fingerprint and get a token. +//! They use `/bot/getUpdates` and `/bot/sendMessage` +//! to communicate with featherChat users. + +use axum::{ + extract::{Path, State}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use base64::Engine; + +use crate::errors::AppResult; +use crate::state::AppState; + +/// Build the bot API routes (nested under `/v1`). +pub fn routes() -> Router { + Router::new() + .route("/bot/register", post(register_bot)) + .route("/bot/:token/getUpdates", post(get_updates)) + .route("/bot/:token/sendMessage", post(send_message)) + .route("/bot/:token/getMe", get(get_me)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Validate a bot token against the `tokens` sled tree. +/// Returns the stored bot info JSON if the token is valid. +fn validate_bot_token(state: &AppState, token: &str) -> Option { + let key = format!("bot:{}", token); + let ivec = state.db.tokens.get(key.as_bytes()).ok()??; + serde_json::from_slice(&ivec).ok() +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct RegisterBotRequest { + name: String, + fingerprint: String, +} + +/// Register a bot and receive a token. +/// +/// `POST /v1/bot/register` +/// +/// ```json +/// { "name": "mybot", "fingerprint": "aabbccdd..." } +/// ``` +async fn register_bot( + State(state): State, + Json(req): Json, +) -> AppResult> { + let fp = req + .fingerprint + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + + let random_bytes: [u8; 16] = rand::random(); + let token = format!( + "{}:{}", + &fp[..fp.len().min(16)], + hex::encode(random_bytes), + ); + + let bot_info = serde_json::json!({ + "name": req.name, + "fingerprint": fp, + "token": token, + "created_at": chrono::Utc::now().timestamp(), + }); + + // Store bot info keyed by token. + let key = format!("bot:{}", token); + state + .db + .tokens + .insert(key.as_bytes(), serde_json::to_vec(&bot_info)?.as_slice())?; + + // Reverse lookup: fingerprint -> token. + let fp_key = format!("bot_fp:{}", fp); + state + .db + .tokens + .insert(fp_key.as_bytes(), token.as_bytes())?; + + tracing::info!( + "Bot registered: {} ({}) token={}...", + req.name, + fp, + &token[..token.len().min(20)] + ); + + Ok(Json(serde_json::json!({ + "ok": true, + "result": { + "token": token, + "name": req.name, + "fingerprint": fp, + } + }))) +} + +/// `GET /bot/:token/getMe` -- returns bot info (Telegram-compatible shape). +async fn get_me( + State(state): State, + Path(token): Path, +) -> Json { + match validate_bot_token(&state, &token) { + Some(info) => Json(serde_json::json!({ + "ok": true, + "result": { + "id": info["fingerprint"], + "is_bot": true, + "first_name": info["name"], + "username": info["name"], + } + })), + None => Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })), + } +} + +/// `POST /bot/:token/getUpdates` -- long-poll for messages sent to this bot. +/// +/// Reads from the `queue::*` key range in the messages sled tree, +/// converts each entry into a Telegram-style `Update` object, and deletes +/// consumed entries. +async fn get_updates( + State(state): State, + Path(token): Path, + Json(params): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(info) => info, + None => { + return Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })) + } + }; + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or(""); + let timeout = params + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let prefix = format!("queue:{}", bot_fp); + let mut updates = Vec::new(); + let mut keys_to_delete = Vec::new(); + let mut update_id = 1u64; + + for item in state.db.messages.scan_prefix(prefix.as_bytes()) { + let (key, value) = match item { + Ok(pair) => pair, + Err(_) => continue, + }; + + if let Ok(wire) = + bincode::deserialize::(&value) + { + match wire { + warzone_protocol::message::WireMessage::Message { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value); + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": id, + "from": { + "id": &sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": &sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": null, + "raw_encrypted": raw_b64, + } + })); + update_id += 1; + } + warzone_protocol::message::WireMessage::KeyExchange { + id, + sender_fingerprint, + .. + } => { + let raw_b64 = base64::engine::general_purpose::STANDARD.encode(&value); + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": id, + "from": { + "id": &sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": &sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": null, + "raw_encrypted": raw_b64, + } + })); + update_id += 1; + } + warzone_protocol::message::WireMessage::CallSignal { + id, + sender_fingerprint, + signal_type, + payload, + .. + } => { + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": id, + "from": { + "id": &sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": &sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "text": format!("/call_{:?}", signal_type), + "call_signal": { + "type": format!("{:?}", signal_type), + "payload": payload, + }, + } + })); + update_id += 1; + } + warzone_protocol::message::WireMessage::FileHeader { + id, + sender_fingerprint, + filename, + file_size, + .. + } => { + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": id, + "from": { + "id": &sender_fingerprint, + "is_bot": false, + "first_name": &sender_fingerprint[..sender_fingerprint.len().min(12)], + }, + "chat": { + "id": &sender_fingerprint, + "type": "private", + }, + "date": chrono::Utc::now().timestamp(), + "document": { + "file_name": filename, + "file_size": file_size, + }, + } + })); + update_id += 1; + } + // Skip receipts — don't deliver as updates. + warzone_protocol::message::WireMessage::Receipt { .. } => {} + // Skip other variants (FileChunk, GroupSenderKey, SenderKeyDistribution). + _ => {} + } + } else if let Ok(bot_msg) = serde_json::from_slice::(&value) { + // Try plaintext bot message (from other bots via sendMessage). + if bot_msg.get("type").and_then(|v| v.as_str()) == Some("bot_message") { + updates.push(serde_json::json!({ + "update_id": update_id, + "message": { + "message_id": bot_msg.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "from": { + "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), + "is_bot": true, + }, + "chat": { + "id": bot_msg.get("from").and_then(|v| v.as_str()).unwrap_or(""), + "type": "private", + }, + "date": bot_msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0), + "text": bot_msg.get("text").and_then(|v| v.as_str()).unwrap_or(""), + } + })); + update_id += 1; + } + } + + keys_to_delete.push(key); + } + + // Remove consumed messages. + for key in &keys_to_delete { + let _ = state.db.messages.remove(key); + } + + // Simplified long-poll: if the queue was empty, wait up to `timeout` seconds + // (capped at 5 s) before returning, giving new messages a chance to arrive. + if updates.is_empty() && timeout > 0 { + let wait = std::cmp::min(timeout, 5); + tokio::time::sleep(std::time::Duration::from_secs(wait)).await; + } + + Json(serde_json::json!({ + "ok": true, + "result": updates, + })) +} + +#[derive(Deserialize)] +struct SendMessageRequest { + chat_id: String, + text: String, +} + +/// `POST /bot/:token/sendMessage` -- send a plaintext message to a user. +/// +/// In v1, bot messages are **not** E2E-encrypted; they are delivered as +/// plain JSON envelopes through the normal routing layer. +async fn send_message( + State(state): State, + Path(token): Path, + Json(req): Json, +) -> Json { + let bot_info = match validate_bot_token(&state, &token) { + Some(info) => info, + None => { + return Json(serde_json::json!({ + "ok": false, + "description": "invalid token", + })) + } + }; + + let to_fp = req + .chat_id + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_lowercase(); + let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot"); + + let msg_id = uuid::Uuid::new_v4().to_string(); + let bot_msg = serde_json::json!({ + "type": "bot_message", + "id": msg_id, + "from": bot_fp, + "text": req.text, + "timestamp": chrono::Utc::now().timestamp(), + }); + let msg_bytes = serde_json::to_vec(&bot_msg).unwrap_or_default(); + + let delivered = state.deliver_or_queue(&to_fp, &msg_bytes).await; + + Json(serde_json::json!({ + "ok": true, + "result": { + "message_id": msg_id, + "chat": { "id": to_fp, "type": "private" }, + "text": req.text, + "date": chrono::Utc::now().timestamp(), + "delivered": delivered, + } + })) +} diff --git a/warzone/crates/warzone-server/src/routes/friends.rs b/warzone/crates/warzone-server/src/routes/friends.rs new file mode 100644 index 0000000..dbb158b --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/friends.rs @@ -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 { + 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, +) -> AppResult> { + 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, + Json(req): Json, +) -> AppResult> { + 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 }))) +} diff --git a/warzone/crates/warzone-server/src/routes/groups.rs b/warzone/crates/warzone-server/src/routes/groups.rs index f866013..c9768e9 100644 --- a/warzone/crates/warzone-server/src/routes/groups.rs +++ b/warzone/crates/warzone-server/src/routes/groups.rs @@ -281,16 +281,22 @@ async fn get_members( None => return Ok(Json(serde_json::json!({ "error": "group not found" }))), }; - // Resolve aliases for each member + // Resolve aliases and online status for each member let mut members_info: Vec = 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, })); } @@ -298,5 +304,6 @@ async fn get_members( "name": group.name, "members": members_info, "count": members_info.len(), + "online_count": online_count, }))) } diff --git a/warzone/crates/warzone-server/src/routes/keys.rs b/warzone/crates/warzone-server/src/routes/keys.rs index 3bf8359..8a778fb 100644 --- a/warzone/crates/warzone-server/src/routes/keys.rs +++ b/warzone/crates/warzone-server/src/routes/keys.rs @@ -46,6 +46,8 @@ struct RegisterRequest { #[serde(default)] device_id: Option, bundle: Vec, + #[serde(default)] + eth_address: Option, } #[derive(Serialize)] @@ -68,6 +70,16 @@ async fn register_keys( 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 }) } diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index f81b01b..bfc9161 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,13 +1,16 @@ mod aliases; pub mod auth; +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; @@ -29,7 +32,10 @@ pub fn router() -> Router { .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) diff --git a/warzone/crates/warzone-server/src/routes/resolve.rs b/warzone/crates/warzone-server/src/routes/resolve.rs new file mode 100644 index 0000000..9408511 --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/resolve.rs @@ -0,0 +1,102 @@ +use axum::{ + extract::{Path, State}, + routing::get, + Json, Router, +}; + +use crate::errors::AppResult; +use crate::state::AppState; + +pub fn routes() -> Router { + 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, + Path(address): Path, +) -> AppResult> { + 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, + "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::().await { + if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { + return Ok(Json(serde_json::json!({ + "address": address, + "fingerprint": 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, + "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, + "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::(); + 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, + "eth_address": eth, + "type": "fingerprint", + }))); + } + + Ok(Json(serde_json::json!({ "error": "unrecognized address format" }))) +} diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index d2e21ef..9775e9f 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -171,6 +171,9 @@ const WEB_HTML: &str = r##" cursor: pointer; font-size: 14px; min-height: 40px; } #send-btn:hover { background: #c73e54; } + .addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; } + .addr:hover { color: #81d4fa; } + @media (max-width: 500px) { .msg { font-size: 0.8em; } #chat-header input { width: 180px; } @@ -207,6 +210,7 @@ const WEB_HTML: &str = r##"
+ @@ -230,6 +234,7 @@ const $peerInput = document.getElementById('peer-input'); // ── State ── let wasmIdentity = null; // WasmIdentity from WASM let myFingerprint = ''; +let myEthAddress = ''; let mySeedHex = ''; let sessions = {}; // peerFP -> { session: WasmSession, data: base64 } let peerBundles = {}; // peerFP -> bundle bytes @@ -298,6 +303,33 @@ function normFP(fp) { return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); } +function makeAddressClickable(text) { + // Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups) + text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) { + const fp = match.replace(/:/g, ''); + return '' + match + ''; + }); + // Match ETH addresses: 0x followed by 40 hex chars + text = text.replace(/(0x[0-9a-fA-F]{40})/g, function(match) { + return '' + match + ''; + }); + return text; +} + +function handleAddrClick(addr) { + const input = document.getElementById('msg-input'); + if (input && input.value.trim().length > 0) { + navigator.clipboard.writeText(addr).then(() => { + addSys('Copied: ' + addr); + }); + } else { + $peerInput.value = addr; + currentGroup = null; + localStorage.setItem('wz-peer', addr); + addSys('Peer set to ' + addr.slice(0,16) + '...'); + } +} + // ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ── async function initWasm() { @@ -442,6 +474,43 @@ async function sendEncrypted(peerFP, plaintext) { return msgId; } +// URL deep links: /message/@alias, /message/0xABC, /group/#ops +function handleDeepLink() { + const path = window.location.pathname; + if (path.startsWith('/message/')) { + const target = decodeURIComponent(path.slice(9)); + if (target) { + setTimeout(() => { + $peerInput.value = target; + if (target.startsWith('@')) { + fetch(SERVER + '/v1/alias/resolve/' + target.slice(1)).then(r => r.json()).then(data => { + if (!data.error) { + $peerInput.value = data.fingerprint; + currentGroup = null; + localStorage.setItem('wz-peer', data.fingerprint); + addSys('Deep link: peer set to ' + target + ' (' + data.fingerprint.slice(0,16) + '...)'); + } else { + addSys('Deep link: unknown alias ' + target); + } + }); + } else { + currentGroup = null; + localStorage.setItem('wz-peer', target); + addSys('Deep link: peer set to ' + target.slice(0,16) + '...'); + } + }, 500); + } + } else if (path.startsWith('/group/')) { + let group = decodeURIComponent(path.slice(7)); + if (group.startsWith('#')) group = group.slice(1); + if (group) { + setTimeout(() => { + groupSwitch(group); + }, 500); + } + } +} + function connectWebSocket() { const fp = normFP(myFingerprint); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -454,6 +523,7 @@ function connectWebSocket() { ws.onopen = () => { dbg('WebSocket connected'); addSys('Real-time connection established'); + handleDeepLink(); }; ws.onmessage = async (event) => { @@ -669,7 +739,11 @@ function addMsg(from, text, isSelf, messageId) { const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent'; receiptHtml = ' ' + receiptIndicator(status) + ''; } - d.innerHTML = '' + ts() + ' ' + lock + '' + esc(from) + ': ' + esc(text) + receiptHtml; + d.innerHTML = '' + ts() + ' ' + lock + '' + makeAddressClickable(esc(from)) + ': ' + makeAddressClickable(esc(text)) + receiptHtml; + // Attach click handler for .addr spans + d.querySelectorAll('.addr').forEach(el => { + el.addEventListener('click', () => handleAddrClick(el.dataset.addr)); + }); $messages.appendChild(d); $messages.scrollTop = $messages.scrollHeight; // Store reference to the receipt span so we can update it later @@ -724,8 +798,21 @@ async function enterChat() { await registerKey(); addSys('Identity loaded: ' + myFingerprint); addSys('Key registered with server'); + + // Fetch ETH address from server + try { + const resolveResp = await fetch(SERVER + '/v1/resolve/' + normFP(myFingerprint)); + const resolveData = await resolveResp.json(); + if (resolveData.eth_address) { + myEthAddress = resolveData.eth_address; + addSys('ETH: ' + myEthAddress); + document.getElementById('hdr-eth').textContent = myEthAddress.slice(0, 10) + '...'; + document.getElementById('hdr-eth').title = myEthAddress; + } + } catch(e) { dbg('ETH resolve failed:', e); } + addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above'); - addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /file · /info'); + addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info'); const savedPeer = localStorage.getItem('wz-peer'); if (savedPeer) $peerInput.value = savedPeer; @@ -758,6 +845,22 @@ async function groupJoin(name) { addSys('Joined group "' + name + '" (' + data.members + ' members)'); } +async function showGroupMembers(groupName) { + try { + const resp = await fetch(SERVER + '/v1/groups/' + groupName + '/members'); + const data = await resp.json(); + if (data.members && data.members.length > 0) { + const online = data.members.filter(m => m.online).length; + addSys('Members of #' + groupName + ' (' + online + '/' + data.members.length + ' online):'); + for (const m of data.members) { + const status = m.online ? '\u{1F7E2}' : '\u26AA'; + const label = m.alias ? '@' + m.alias : m.fingerprint.slice(0, 16) + '...'; + addSys(' ' + status + ' ' + label + (m.is_creator ? ' *' : '')); + } + } + } catch(e) { dbg('Failed to fetch members:', e); } +} + async function groupSwitch(name) { // Auto-join await groupJoin(name); @@ -767,6 +870,7 @@ async function groupSwitch(name) { currentGroup = name; $peerInput.value = '#' + name; addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')'); + await showGroupMembers(name); } async function groupList() { @@ -838,6 +942,7 @@ async function doSend() { const aliasData = await aliasResp.json(); const aliasStr = aliasData.alias ? ' (@' + aliasData.alias + ')' : ''; addSys('Fingerprint: ' + myFingerprint + aliasStr); + if (myEthAddress) addSys('ETH Address: ' + myEthAddress); return; } if (text === '/clear') { $messages.innerHTML = ''; return; } @@ -871,6 +976,11 @@ async function doSend() { } catch(e) { addSys('Bundle info error: ' + e); } return; } + if (text === '/seed') { + addSys('Your recovery seed (keep secret!):'); + addSys(wasmIdentity.mnemonic()); + return; + } if (text === '/quit') { window.close(); return; } if (text === '/glist') { await groupList(); return; } if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; } @@ -970,6 +1080,32 @@ async function doSend() { } return; } + if (text === '/friend' || text === '/friends') { + try { + const resp = await fetch(SERVER + '/v1/friends', { + headers: { 'Authorization': 'Bearer ' + normFP(myFingerprint) } + }); + const data = await resp.json(); + if (data.data) { + addSys('Friends:'); + addSys('(encrypted friend list stored on server -- use TUI for full friend management)'); + } else { + addSys('No friends yet. Use /friend
to add.'); + } + } catch(e) { addSys('Error: ' + e.message); } + return; + } + if (text.startsWith('/friend ')) { + const addr = text.slice(8).trim(); + if (!addr) { addSys('Usage: /friend
'); return; } + addSys('Friend management requires TUI client (encrypted locally). Use warzone-client for full support.'); + addSys('Hint: /friend in TUI to manage friends with E2E encryption.'); + return; + } + if (text.startsWith('/unfriend ')) { + addSys('Friend management requires TUI client (encrypted locally).'); + return; + } if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; } // Send to group or DM @@ -1021,6 +1157,9 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB document.getElementById('btn-recover').onclick = () => doRecover(); document.getElementById('btn-enter').onclick = () => enterChat(); document.getElementById('send-btn').onclick = () => doSend(); +document.getElementById('hdr-eth').onclick = function() { + if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address')); +}; document.getElementById('file-input').onchange = async function() { if (!this.files.length) return; const file = this.files[0]; diff --git a/warzone/docs/CLIENT.md b/warzone/docs/CLIENT.md index 35d878e..5e501f6 100644 --- a/warzone/docs/CLIENT.md +++ b/warzone/docs/CLIENT.md @@ -1,5 +1,7 @@ # Warzone Client -- Operation Guide +**Version:** 0.0.21 + --- ## 1. Installation @@ -21,313 +23,509 @@ The binary is at `target/release/warzone`. You can copy it anywhere or add cargo install --path crates/warzone-client ``` ---- +### Build the WASM Module (Web Client) -## 2. Quick Start +Requires wasm-pack. ```bash -# 1. Generate a new identity -warzone init - -# 2. Register your key bundle with a server -warzone register -s http://wz.example.com:7700 - -# 3. Send an encrypted message -warzone send a3f8:c912:44be:7d01 "Hello from Warzone" -s http://wz.example.com:7700 - -# 4. Poll for incoming messages -warzone recv -s http://wz.example.com:7700 +cd crates/warzone-wasm +wasm-pack build --target web +# Output in pkg/ — copy to web client directory ``` --- -## 3. CLI Commands +## 2. TUI Architecture -### warzone init +The interactive client is built on **ratatui** (rendering) and **crossterm** +(terminal I/O). The event loop polls at **100 ms** intervals, giving a +responsive feel without busy-waiting. -Generate a new identity (seed, keypair, and pre-keys). +### Module Layout + +The TUI lives in `crates/warzone-client/src/tui/` and is split into seven +modules: + +| Module | Responsibility | +|-----------------|---------------------------------------------------------| +| `types` | Core data structures: `App`, `ChatLine`, `ReceiptStatus`, `PendingFileTransfer`, constants (`MAX_FILE_SIZE`, `CHUNK_SIZE`) | +| `draw` | Rendering: header bar, message list with timestamps and receipt indicators, input box with unread badge, scroll windowing | +| `commands` | All `/`-prefixed command handlers (peer, alias, group, file, history, friends, devices, etc.) and message send logic | +| `input` | Key event dispatch: text editing, cursor movement, scroll, quit | +| `file_transfer` | Chunked file send: reads file, SHA-256 hash, splits into 64 KB encrypted chunks | +| `network` | WebSocket receive loop (with HTTP polling fallback), incoming message decryption, receipt handling, session auto-recovery | +| `mod` | Public entry point `run_tui()`: sets up terminal, spawns network task, runs the 100 ms event loop | + +### Event Loop + +``` +loop { + terminal.draw(app) // ratatui render pass + if event::poll(100ms) { // crossterm poll + handle key event // Enter → send; everything else → input.rs + } + if app.should_quit { break } +} +``` + +Messages arrive asynchronously on a background tokio task (`network::poll_loop`) +and are pushed into a shared `Arc>>`. + +--- + +## 3. CLI Subcommands + +### `warzone init` + +Generate a new identity (seed, keypair, pre-keys). ```bash $ warzone init -Identity generated! +Set passphrase (empty for no encryption): **** +Confirm passphrase: **** -Fingerprint: b7d1:e845:0022:9f3a +Your identity: + Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 + Mnemonic: abandon ability able about above absent absorb abstract ... -Recovery mnemonic (WRITE THIS DOWN): - - 1. abandon 2. ability 3. able 4. about - 5. above 6. absent 7. absorb 8. abstract - 9. absurd 10. abuse 11. access 12. accident -13. account 14. accuse 15. achieve 16. acid -17. acoustic 18. acquire 19. across 20. act -21. action 22. actor 23. actress 24. actual - -Seed saved to ~/.warzone/identity.seed -Generated 1 signed pre-key + 10 one-time pre-keys - -To register with a server, run: - warzone send -s http://server:7700 - -Or register your key bundle manually: - (bundle auto-registered on first send) +SAVE YOUR MNEMONIC — it is the ONLY way to recover your identity. ``` **What happens:** + 1. Generates 32 random bytes (seed) from `OsRng`. 2. Derives Ed25519 signing key and X25519 encryption key from the seed. 3. Converts seed to a 24-word BIP39 mnemonic and displays it. -4. Saves the raw seed to `~/.warzone/identity.seed` (mode 0600 on Unix). +4. Prompts for a passphrase. Encrypts the seed with Argon2id + ChaCha20-Poly1305 + and saves to `~/.warzone/identity.seed` (mode 0600 on Unix). An empty + passphrase stores the seed in plaintext. 5. Generates 1 signed pre-key (id=1) and 10 one-time pre-keys (ids 0-9). 6. Stores pre-key secrets in the local sled database at `~/.warzone/db/`. 7. Saves the public pre-key bundle to `~/.warzone/bundle.bin`. --- -### warzone recover \ +### `warzone recover ` -Recover an identity from a BIP39 mnemonic. +Recover an identity from a 24-word BIP39 mnemonic. ```bash $ warzone recover abandon ability able about above absent absorb abstract \ absurd abuse access accident account accuse achieve acid \ acoustic acquire across act action actor actress actual -Identity recovered! -Fingerprint: b7d1:e845:0022:9f3a -Seed saved to ~/.warzone/identity.seed +Set passphrase (empty for no encryption): **** +Confirm passphrase: **** +Identity recovered. Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 ``` -**Note:** recovery restores the seed and keypair but does NOT restore -pre-keys or sessions. You will need to run `warzone init`-style pre-key -generation separately or your contacts will need to re-establish sessions. +Recovery restores the seed and keypair. Pre-keys and sessions are NOT restored; +contacts will need to re-establish sessions. --- -### warzone info +### `warzone info` Display your fingerprint and public keys. ```bash $ warzone info -Fingerprint: b7d1:e845:0022:9f3a -Signing key: 3a7c... (64 hex chars) -Encryption key: 9d2f... (64 hex chars) +Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 +Signing key: 3a7b... (64 hex chars) +Encryption key: 9f2c... (64 hex chars) ``` Requires a saved identity (`~/.warzone/identity.seed`). --- -### warzone register +### `warzone tui` / `warzone chat [peer]` -Register your pre-key bundle with a server. +Launch the interactive TUI client. ```bash -$ warzone register -s http://wz.example.com:7700 -Bundle registered with http://wz.example.com:7700 +$ warzone chat --server http://wz.example.com:7700 +$ warzone chat a3f8:c912:44be:7d01:... --server http://wz.example.com:7700 +$ warzone chat @alice --server http://wz.example.com:7700 ``` +An optional `peer` argument (fingerprint or `@alias`) pre-sets the active +DM target. + **Flags:** -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--server` | `-s` | `http://localhost:7700` | Server URL | - -This uploads `~/.warzone/bundle.bin` to the server. Registration is also -performed automatically on the first `send`. +| Flag | Short | Default | Description | +|------------|-------|-----------------------|--------------| +| `--server` | `-s` | `http://localhost:7700` | Server URL | --- -### warzone send +### `warzone send ` -Send an encrypted message to a recipient. +Send an encrypted message. Recipient can be a fingerprint or `@alias`. ```bash -$ warzone send a3f8:c912:44be:7d01 "Hello, are you safe?" -s http://wz.example.com:7700 -No existing session. Fetching key bundle for a3f8:c912:44be:7d01... -Bundle registered with http://wz.example.com:7700 -Message sent to a3f8:c912:44be:7d01 +$ warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://wz.example.com:7700 +$ warzone send @alice "Hello!" --server http://wz.example.com:7700 ``` -**Arguments:** - -| Argument | Description | -|----------|-------------| -| `recipient` | Recipient fingerprint (e.g. `a3f8:c912:44be:7d01`) | -| `message` | Message text (quote if it contains spaces) | - -**Flags:** - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--server` | `-s` | `http://localhost:7700` | Server URL | - **Behavior:** -1. Auto-registers your bundle with the server (if not already done). + +1. Auto-registers your bundle with the server if needed. 2. Checks for an existing Double Ratchet session with the recipient. -3. If no session exists: - - Fetches recipient's pre-key bundle from the server. - - Verifies the signed pre-key signature. - - Performs X3DH key exchange. - - Initializes the Double Ratchet as Alice (initiator). - - Sends a `WireMessage::KeyExchange` containing the X3DH parameters - and the first encrypted message. -4. If a session exists: - - Encrypts using the existing ratchet. - - Sends a `WireMessage::Message`. +3. If no session: fetches the recipient's pre-key bundle, verifies the signed + pre-key signature, performs X3DH, initializes the ratchet as Alice, and + sends a `WireMessage::KeyExchange` containing the X3DH parameters and the + first encrypted message. +4. If a session exists: encrypts with the existing ratchet and sends a + `WireMessage::Message`. 5. Updates the local session state. --- -### warzone recv +### `warzone recv` Poll for and decrypt incoming messages. ```bash -$ warzone recv -s http://wz.example.com:7700 -Polling for messages as b7d1:e845:0022:9f3a... -Received 2 message(s): - - [new session] a3f8:c912:44be:7d01: Hello, are you safe? - a3f8:c912:44be:7d01: I'm sending supplies tomorrow. +$ warzone recv --server http://wz.example.com:7700 ``` -**Flags:** - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--server` | `-s` | `http://localhost:7700` | Server URL | - -**Behavior:** -1. Polls `/v1/messages/poll/{our_fingerprint}`. -2. For each message: - - Deserializes the `WireMessage` from bincode. - - **KeyExchange:** loads signed pre-key secret and (if applicable) - one-time pre-key secret from local storage, performs X3DH respond, - initializes ratchet as Bob, decrypts the message, and saves the session. - - **Message:** loads existing session, decrypts with the ratchet, saves - updated session state. -3. Prints decrypted messages to stdout. - -**Note:** messages are currently NOT acknowledged after polling. They will -be returned again on the next poll. Acknowledgment is TODO. +Fetches messages from `/v1/messages/poll/{fingerprint}`, deserializes each +`WireMessage`, performs X3DH respond or ratchet decrypt as appropriate, and +prints plaintext to stdout. --- -### warzone chat +### `warzone backup [output]` -Launch the interactive TUI. +Export an encrypted backup of local data (sessions, pre-keys). ```bash -$ warzone chat -s http://wz.example.com:7700 -TODO: launch TUI connected to http://wz.example.com:7700 +$ warzone backup my-backup.wzb +Backup saved to my-backup.wzb (4096 bytes encrypted) ``` -**Status:** not yet implemented. The TUI will use `ratatui` and `crossterm` -(dependencies are already in `Cargo.toml`). Planned for Phase 2. +The backup is encrypted with `HKDF(seed, info="warzone-history")` + +ChaCha20-Poly1305. + +**Backup file format:** + +``` +WZH1 (4 bytes) + nonce (12) + ciphertext + +Plaintext: JSON { + "version": 1, + "sessions": { "": "base64_bincode", ... }, + "pre_keys": { "spk:1": "base64_bytes", "otpk:1": "base64_bytes", ... } +} +``` --- -## 4. Identity Management +### `warzone restore ` -### Storage Layout - -``` -~/.warzone/ - identity.seed # 32-byte raw seed (plaintext -- encryption is TODO) - bundle.bin # bincode-serialized PreKeyBundle (public data) - db/ # sled database directory - sessions/ # Double Ratchet state per peer - pre_keys/ # signed and one-time pre-key secrets -``` - -### File Permissions - -On Unix, `identity.seed` is created with mode `0600` (owner read/write only). -The sled database directory inherits default permissions. - -### Seed Security - -**Current state:** the seed is stored as **plaintext** 32 bytes. This is a -known Phase 1 limitation. - -**Planned (Phase 2):** encrypt the seed at rest using: -- Passphrase input at startup -- Argon2id key derivation from passphrase -- ChaCha20-Poly1305 encryption of the seed bytes - -### Mnemonic Backup - -The 24-word BIP39 mnemonic shown during `init` is the ONLY way to recover -your identity if you lose `~/.warzone/`. Write it down on paper and store it -securely. - -The mnemonic is displayed once at generation time and can be recovered from -the seed using the protocol library, but the CLI does not currently expose a -"show mnemonic" command. - -### Recovery +Restore from an encrypted backup. Requires the same seed (passphrase prompt). ```bash -warzone recover word1 word2 word3 ... word24 +$ warzone restore my-backup.wzb +Restored 12 entries from my-backup.wzb ``` -This recreates `~/.warzone/identity.seed` with the same seed. The same -fingerprint and keypairs are derived deterministically. However: - -- Pre-keys are NOT regenerated. Run `warzone init` on a fresh directory to - generate new pre-keys (this will also generate a new seed, so you would need - to coordinate). -- Sessions are NOT recovered. All contacts will need to establish new sessions. - -**TODO:** a `recover` flow that also regenerates pre-keys without creating a -new seed. +Merges data without overwriting existing entries. --- -## 5. Web Client +## 4. TUI Features -The web client is served by the server at `/`. Open it in a browser: +### Message Timestamps -``` -http://localhost:7700/ -``` +Every message is rendered with a `[HH:MM]` prefix in dark gray, derived from +`chrono::Local::now()` at receive/send time. -### Features +### Message Scrolling -- **Generate New Identity:** creates a random 32-byte seed in the browser. -- **Recover from Mnemonic:** paste a hex-encoded seed (not BIP39 words; - hex encoding is used as a placeholder). -- **Chat interface:** dark-themed monospace UI with message display. -- **Commands:** - - `/help` -- show available commands - - `/info` -- show your fingerprint - - `/seed` -- display your seed (hex-encoded) +The message area supports scrolling with a "pinned to bottom" model: + +- `scroll_offset = 0` means the newest messages are visible. +- Scrolling up increases the offset; scrolling down decreases it. +- The visible window is computed as `items[total - offset - height .. total - offset]`. + +### Connection Status Indicator + +The header bar displays a colored dot after the server URL: + +- Green dot: WebSocket connection active. +- Red dot: disconnected (HTTP polling fallback or reconnecting). + +### Unread Badge + +When `scroll_offset > 0`, the input box title changes from `" message "` to +`" [N new] "` showing how many messages are below the current scroll position. +This makes it obvious that new content has arrived while reading history. + +### Terminal Bell + +A terminal bell (`\x07`) is emitted on every incoming DM (both `KeyExchange` +and `Message` wire types). This triggers a system notification in most terminal +emulators. + +### Receipt Indicators + +Sent messages display delivery status after the message text: + +| Indicator | Meaning | +|-----------------|----------------------------------------| +| Single tick | Sent (no confirmation yet) | +| Double tick | Delivered (decrypted by recipient) | +| Double tick blue| Read (viewed by recipient) | + +### Session Auto-Recovery + +When decryption fails on an incoming message, the TUI automatically: + +1. Deletes the corrupted session from the local database. +2. Displays a system message: `[session reset] Decryption failed for . Session cleared -- next message will re-establish.` + +The next incoming `KeyExchange` from that peer will create a fresh session +without manual intervention. + +--- + +## 5. Full Command Reference + +All commands start with `/` and are entered in the TUI input box. + +### Peer and Navigation + +| Command | Short | Description | +|------------------------|---------|----------------------------------------------| +| `/peer ` | `/p` | Set the active DM peer (fingerprint or @alias) | +| `/dm` | | Switch to DM mode (clear group context) | +| `/reply` | `/r` | Switch to the last person who DM'd you | +| `/info` | | Display your fingerprint | +| `/eth` | | Display your Ethereum address (derived from seed) | +| `/seed` | | Display your 24-word recovery mnemonic | +| `/quit` | `/q` | Exit the TUI | +| `/help` | `/?` | Show the built-in help text | + +### Alias Management + +| Command | Description | +|-----------------------|--------------------------------------------| +| `/alias ` | Register an alias for your fingerprint. Returns a recovery key -- save it. | +| `/unalias` | Remove your alias from the server | +| `/aliases` | List all registered aliases on the server | + +Alias rules: 1-32 alphanumeric characters (plus `_` and `-`), case-insensitive, +normalized to lowercase. TTL is 365 days of inactivity with a 30-day grace +period before reclamation. + +### Contacts and History + +| Command | Short | Description | +|------------------------|---------|------------------------------------------| +| `/contacts` | `/c` | List all contacts with message counts | +| `/history [peer]` | `/h` | Show message history (last 50 messages). Uses current peer if set. | + +### Group Commands + +| Command | Description | +|-------------------------|------------------------------------------| +| `/g ` | Switch to group (auto-join if needed) | +| `/gcreate ` | Create a new group (you become creator) | +| `/gjoin ` | Join an existing group | +| `/gleave` | Leave the current group | +| `/gkick ` | Kick a member (creator only) | +| `/gmembers` | List members of the current group | +| `/glist` | List all groups on the server | + +Group messages use Sender Keys for O(1) encryption per message. Each member +generates a `SenderKey` distributed via 1:1 encrypted channels. Keys rotate on +member join/leave. + +### File Transfer + +| Command | Description | +|-------------------|----------------------------------------------| +| `/file ` | Send a file to the current peer or group | + +Constraints: + +- Maximum file size: 10 MB +- Chunk size: 64 KB +- Files are sent as `FileHeader` + encrypted `FileChunk` wire messages +- SHA-256 verification on receipt +- Received files are saved to `~/.warzone/downloads/` + +### Device Management + +| Command | Description | +|-----------------------|------------------------------------------| +| `/devices` | List your active device sessions | +| `/kick ` | Kick a specific device session | + +--- + +## 6. Keyboard Shortcuts + +### Text Editing + +| Key | Action | +|------------------|---------------------------------| +| Left / Right | Move cursor one character | +| Home / Ctrl+A | Move to beginning of line | +| End / Ctrl+E | Move to end of line | +| Backspace | Delete character before cursor | +| Delete | Delete character at cursor | +| Ctrl+U | Clear entire input line | +| Ctrl+K | Kill from cursor to end of line | +| Ctrl+W | Delete word before cursor | +| Alt+Backspace | Delete word before cursor | +| Alt+Left | Jump one word left | +| Alt+Right | Jump one word right | + +### Scrolling + +| Key | Action | +|------------------|------------------------------------------| +| PageUp | Scroll up 10 messages | +| PageDown | Scroll down 10 messages | +| Up | Scroll up 1 message (when input is empty)| +| Down | Scroll down 1 message (when input is empty)| +| End | Snap to bottom (when input is empty) | +| Ctrl+End | Snap to bottom (always) | + +### Quit + +| Key | Action | +|------------------|---------| +| Ctrl+C | Quit | +| Esc | Quit | + +--- + +## 7. Friend List + +The friend list is an E2E encrypted contact list stored on the server as an +opaque blob. The server never sees the plaintext. + +### Encryption + +- Key derivation: `HKDF(seed, info="warzone-friends")` produces a 32-byte key. +- Encryption: ChaCha20-Poly1305 with AAD `"warzone-friends-aad"`. +- Plaintext format: JSON-serialized `FriendList` containing address, alias, + and `added_at` timestamp per friend. + +### Commands + +| Command | Description | +|------------------------|------------------------------------------------| +| `/friend` | List all friends with online/offline presence | +| `/friend
` | Add a friend (fingerprint or ETH address) | +| `/unfriend
` | Remove a friend | + +When listing friends, the TUI queries the server's presence endpoint for each +friend to show real-time online/offline status. ### How It Works -1. Seed is generated with `crypto.getRandomValues(32)`. -2. ECDH P-256 keypair is derived (not X25519 -- Web Crypto limitation). -3. Fingerprint is `SHA-256(ECDH_public_key)[0..16]` formatted as 4 hex - groups. -4. Seed is saved in `localStorage` under key `wz-seed`. -5. On page load, the client tries to auto-load a saved seed. -6. Public key is registered with the server via `POST /v1/keys/register`. -7. Messages are polled every 5 seconds from `/v1/messages/poll/{fingerprint}`. +1. On `/friend
`: the client fetches the current encrypted blob from + the server, decrypts it, adds the entry, re-encrypts, and uploads. +2. On `/unfriend
`: same fetch-decrypt-modify-encrypt-upload cycle. +3. On `/friend` (no argument): fetches and decrypts the blob, then checks + `/v1/presence/` for each friend. -### Limitations - -- **No cross-client compatibility:** the web client uses P-256 while the CLI - uses X25519/Ed25519. Messages between the two cannot be decrypted. This - will be resolved in Phase 2 (WASM port of the protocol library). -- **No Double Ratchet:** message decryption is not implemented in JS. - Received messages display as `[encrypted message]`. -- **No BIP39:** seed is shown as hex bytes, not mnemonic words. -- **Unencrypted seed storage:** `localStorage` is accessible to any JS on - the same origin. +The server stores the blob at `POST /v1/friends` and returns it at +`GET /v1/friends`. It has no knowledge of the contents. --- -## 6. Session Management +## 8. Local Storage + +### Directory Layout + +``` +~/.warzone/ + identity.seed # Encrypted seed (Argon2id + ChaCha20-Poly1305) + bundle.bin # bincode-serialized PreKeyBundle (public data) + db/ # sled database directory + sessions/ # Double Ratchet state per peer (keyed by hex fingerprint) + pre_keys/ # Signed and one-time pre-key secrets + contacts/ # Contact metadata and message counts + history/ # Message history per peer + sender_keys/ # Sender Key state for group encryption + downloads/ # Received files from /file transfers +``` + +### Seed Encryption + +The seed file uses a fixed format: + +``` +WZS1 (4 bytes magic) + salt (16) + nonce (12) + ciphertext (48) + +Encryption: Argon2id(passphrase, salt) -> 32-byte key + ChaCha20-Poly1305(key, nonce, seed) -> ciphertext +``` + +An empty passphrase at `init` time stores the seed in plaintext (for testing +only). The seed file is created with mode `0600` (owner read/write) on Unix. + +### Mnemonic Backup + +The 24-word BIP39 mnemonic shown during `init` is the only way to recover +your identity if you lose `~/.warzone/`. Write it down on paper. You can also +view it later with `/seed` in the TUI. + +--- + +## 9. Web Client + +The web client is served by the server at `/` and uses a **WASM bridge** +(`warzone-wasm`) that exposes the exact same cryptographic primitives as the +CLI: X25519, ChaCha20-Poly1305, X3DH, Double Ratchet. + +### Features + +- **Same crypto as TUI:** the WASM module wraps `warzone-protocol` directly, + so web-to-CLI interoperability is fully supported. +- **URL deep links:** paths like `/message/@alias`, `/message/0xABC`, and + `/group/#ops` auto-navigate to the corresponding conversation. +- **Clickable addresses:** fingerprints and aliases in the chat are rendered + as interactive links. +- **Service worker cache:** all shell assets (`/`, WASM JS, WASM binary, + manifest, icon) are cached by a versioned service worker (`wz-v2`). The + cache name is bumped on updates to force refresh. +- **PWA support:** includes a manifest and install prompt (`/install` command). +- **BIP39 mnemonic:** seed is displayed as 24 words via the WASM bridge + (not hex). + +### Web-Only Commands + +| Command | Description | +|-------------------|----------------------------------------------------| +| `/selftest` | Run WASM crypto self-test (X3DH + ratchet cycle) | +| `/bundleinfo` | Debug: show bundle details (keys, sizes) | +| `/debug` | Toggle debug mode (verbose output) | +| `/reset` | Clear identity and all local data | +| `/install` | Show PWA installation instructions | +| `/sessions` | List active ratchet sessions | +| `/admin-unalias` | Admin: remove any alias (requires admin password) | + +### Web Client Storage + +Data is stored in `localStorage`: + +| Key | Value | Purpose | +|----------------------|--------------------------------|----------------------------| +| `wz_seed` | hex seed (64 chars) | Identity seed | +| `wz_spk_secret` | hex SPK secret (64 chars) | Signed pre-key secret | +| `wz_session:` | base64 ratchet state | Per-peer session | +| `wz_contacts` | JSON contact list | Contact metadata | + +--- + +## 10. Session Management ### How Sessions Work @@ -336,172 +534,63 @@ by their fingerprint. 1. **First message to a peer:** X3DH key exchange establishes a shared secret. The ratchet is initialized. The session is saved in `~/.warzone/db/` - under the `sessions` tree, keyed by the peer's fingerprint (hex-encoded). + under the `sessions` tree, keyed by the peer's hex fingerprint. 2. **Subsequent messages:** the ratchet state is loaded, used to encrypt or decrypt, then saved back. -3. **Bidirectional:** both parties maintain the same session. When Bob - receives Alice's KeyExchange, he initializes his side of the ratchet. From - then on, both use `WireMessage::Message`. +3. **Bidirectional:** when Bob receives Alice's `KeyExchange`, he initializes + his side. From then on, both use `WireMessage::Message`. -### Session Storage +### Session Auto-Recovery -Sessions are serialized with `bincode` and stored in the `sessions` sled -tree. The key is the peer's 32-character hex fingerprint. +On decrypt failure, the TUI deletes the corrupted session and displays a +warning. The next incoming `KeyExchange` from that peer re-establishes the +session automatically. No manual intervention required. -### Session Reset +### Multi-Device -There is currently no command to reset a session. If a session becomes -corrupted or out of sync: - -1. Delete the local database: `rm -rf ~/.warzone/db/` -2. Re-run `warzone init` to generate new pre-keys. -3. Re-register with the server. -4. Your contact must also reset their session with you. - -**TODO (Phase 2):** a `warzone reset-session ` command. +The server stores per-device bundles (`device::`). Multiple +WebSocket connections per fingerprint are supported -- all connected devices +receive messages. Ratchet sessions are per-device and not synchronized; use +`warzone backup` / `warzone restore` to transfer session state. --- -## 7. Pre-Key Management - -### What Are Pre-Keys - -Pre-keys enable asynchronous session establishment. When Alice wants to -message Bob for the first time: - -1. Alice fetches Bob's **pre-key bundle** from the server. -2. The bundle contains Bob's public identity key, a signed pre-key, and - optionally a one-time pre-key. -3. Alice uses these to perform X3DH without Bob being online. - -### Pre-Key Types - -| Type | Quantity | Lifetime | Purpose | -|------|----------|----------|---------| -| Signed pre-key | 1 (id=1) | Long-term (no rotation yet) | Medium-term DH key, signed by identity | -| One-time pre-keys | 10 (ids 0-9) | Single use | Consumed during X3DH, then deleted | - -### When to Replenish - -One-time pre-keys are consumed when someone initiates a session with you. -After all 10 are used, X3DH falls back to using only the signed pre-key -(DH4 is skipped), which provides slightly weaker security properties. - -**Current state:** there is no automatic replenishment. You must manually -re-initialize if you expect many incoming new sessions. - -**TODO (Phase 2):** the server will notify the client when one-time pre-key -supply is low, and the client will upload fresh ones automatically. - ---- - -## 8. Security Model - -### What Is Encrypted - -- **Message body:** encrypted with ChaCha20-Poly1305 using per-message keys - from the Double Ratchet. Even the server cannot read it. - -### What Is NOT Encrypted - -- **Sender fingerprint:** visible to the server and anyone intercepting - traffic. -- **Recipient fingerprint:** visible to the server (needed for routing). -- **Message size:** visible to the server. -- **Timing:** when messages are sent and received. -- **IP addresses:** visible to the server and network observers. -- **Seed on disk:** stored as plaintext (encryption TODO). - -### Threat Model - -| Threat | Protected? | Notes | -|--------|-----------|-------| -| Server reads messages | Yes | E2E encryption; server sees only ciphertext | -| Network eavesdropper reads messages | Yes | E2E encryption | -| Server impersonates a user | Partially | Pre-key signatures prevent forgery of signed pre-keys, but the server could substitute a fake bundle (no key transparency yet) | -| Compromised past session key | Yes | Forward secrecy via chain ratchet; break-in recovery via DH ratchet | -| Stolen device (seed file) | No | Seed is plaintext on disk (encryption TODO) | -| Metadata analysis (who talks to whom) | No | Fingerprints visible to server | -| Active MITM on first contact | Partially | TOFU model; no out-of-band verification mechanism in the client yet | -| One-time pre-keys exhausted | Graceful degradation | X3DH works without OT pre-keys but with reduced replay protection | - -### Trust Model - -**Trust on first use (TOFU):** the first time you message someone, you trust -that the server returns their genuine pre-key bundle. There is no -verification step yet. - -**Planned (Phase 3):** DNS-based key transparency where users publish -self-signed public keys in DNS TXT records, allowing cross-verification -independent of the server. - ---- - -## 9. Troubleshooting +## 11. Troubleshooting ### "No identity found. Run `warzone init` first." -You haven't generated an identity, or `~/.warzone/identity.seed` is missing. - -```bash -warzone init -``` +`~/.warzone/identity.seed` is missing. Run `warzone init`. ### "No bundle found. Run `warzone init` first." -The pre-key bundle file `~/.warzone/bundle.bin` is missing. This happens if -you ran `recover` without a full `init`. - -Re-run `warzone init` (this will generate a NEW identity). To keep your -recovered identity, you would need to manually regenerate pre-keys (not yet -supported as a standalone command). +`~/.warzone/bundle.bin` is missing. This happens if you ran `recover` without +regenerating pre-keys. Re-run `warzone init` (generates a new identity). ### "failed to fetch recipient's bundle. Are they registered?" -The recipient has not registered their pre-key bundle with the server, or -you are using the wrong server URL, or the fingerprint is incorrect. - -- Verify the fingerprint (ask the recipient for theirs via `warzone info`). -- Verify the server URL. -- Ask the recipient to run `warzone register -s `. +The recipient has not registered with the server, or the fingerprint / alias +is wrong, or the server URL is incorrect. Verify with `warzone info` and +`warzone register`. ### "X3DH respond failed" / "missing signed pre-key" -Your signed pre-key secret is missing from the local database. This can -happen if: -- The database was deleted or corrupted. -- You recovered an identity but did not regenerate pre-keys. +Signed pre-key secret missing from local database. Database may have been +deleted or corrupted. Re-initialize with `warzone init`. -Fix: re-initialize with `warzone init` (generates a new identity) or restore -from backup. +### "[session reset] Decryption failed" -### "decrypt failed" / "no session" - -- **"no session"**: you received a `WireMessage::Message` from someone you - have no session with. This means you missed their initial `KeyExchange` - message, or your session database was lost. Ask them to re-send their first - message. -- **"decrypt failed"**: the ratchet state is out of sync. This can happen if - one side's state was lost or if messages were duplicated. Reset the session - on both sides. - -### Messages Keep Reappearing on recv - -Messages are not auto-acknowledged after polling. This is a known Phase 1 -limitation. The same messages will be returned on every `recv` call. - -**Workaround:** none currently. Acknowledgment will be added in Phase 2. +The TUI auto-recovery has cleared the corrupted session. Ask the other party +to send a new message -- a fresh `KeyExchange` will re-establish the session. ### Corrupted Database -If `~/.warzone/db/` is corrupted: - ```bash +# Back up your seed first +cp ~/.warzone/identity.seed ~/identity.seed.bak rm -rf ~/.warzone/db/ warzone init # regenerate pre-keys (NOTE: generates a new identity) +# To keep your old identity, recover from mnemonic after: +warzone recover <24 words> ``` - -To keep your existing identity, manually copy `identity.seed` before -deleting, then use `warzone recover` after re-init. diff --git a/warzone/docs/LLM_HELP.md b/warzone/docs/LLM_HELP.md new file mode 100644 index 0000000..0bcd2f8 --- /dev/null +++ b/warzone/docs/LLM_HELP.md @@ -0,0 +1,159 @@ +# featherChat Help Reference + +featherChat (codename: warzone) = E2E encrypted messenger. TUI client, web client (WASM), federated servers. Crypto: X3DH key exchange + Double Ratchet. Identity = Ed25519 keypair from 24-word seed. + +## Commands + +cmd | action | example +--- | --- | --- +/help, /? | show help | /help +/info | show your fp | /info +/eth | show ETH addr | /eth +/seed | show 24-word recovery mnemonic | /seed +/peer , /p | set DM peer | /peer abc123 or /peer @alice +/reply, /r | reply to last DM sender | /r +/dm | switch to DM mode (clear peer) | /dm +/contacts, /c | list contacts + msg counts | /c +/history, /h [fp] | show conversation history (50 msgs) | /h abc123 +/alias | register alias for yourself | /alias alice +/aliases | list all registered aliases | /aliases +/unalias | remove your alias | /unalias +/friend | list friends + online status | /friend +/friend | add friend | /friend @bob +/unfriend | remove friend | /unfriend @bob +/devices | list active device sessions | /devices +/kick | kick a device session | /kick dev_abc +/g | switch to group (auto-join) | /g ops +/gcreate | create group | /gcreate ops +/gjoin | join group | /gjoin ops +/glist | list all groups | /glist +/gleave | leave current group | /gleave +/gkick | kick member (creator only) | /gkick abc123 +/gmembers | list group members + status | /gmembers +/file | send file (max 10MB, 64KB chunks) | /file ./doc.pdf +/quit, /q | exit | /q + +Navigation: PageUp/PageDown scroll msgs, Up/Down scroll by 1 (empty input), Ctrl+C or Esc quit. + +## Addressing + +Format | Example | Notes +--- | --- | --- +Fingerprint | abc123def456... | hex string, derived from Ed25519 pubkey +ETH address | 0x742d35Cc... | derived from same seed, checksum format +@alias | @alice | 1-32 alphanum chars, globally unique, 365d TTL + +All 3 formats work in /peer. Aliases resolve to fp via server. One alias per user. Register with /alias, recover with recovery key. + +## Quick Start + +1. `warzone init` -- generates seed, saves identity.seed, prints 24-word mnemonic. WRITE IT DOWN. +2. `warzone register --server https://srv.example.com` -- uploads prekey bundle to srv +3. `warzone tui --server https://srv.example.com` -- opens TUI, connects WebSocket +4. `/peer @alice` or `/peer ` -- set recipient +5. Type msg, press Enter -- encrypted + sent + +Recovery: `warzone recover` -- enter 24 words to restore identity on new device. + +## Groups + +- /gcreate -- create, you become creator + first member +- /gjoin -- join existing (or auto-join via /g ) +- type msg in group mode -- fan-out encrypted per-member (sender keys) +- /gleave -- leave current group +- /gmembers -- shows fp, alias, online status, creator flag +- /gkick -- creator only, removes member + +Groups auto-create on join if they don't exist. Server fans out per-member encrypted msgs. + +## Files + +/file -- sends to current peer/group. Max 10MB. Auto-chunked at 64KB. Includes filename, size, SHA-256 hash. Receiver auto-reassembles. + +## Friends + +- /friend -- list all friends with online/offline status +- /friend -- add (fp, ETH, or @alias) +- /unfriend -- remove +- Friend list stored encrypted on srv (only you can decrypt with your seed) +- Shows alias resolution + presence status + +## Devices + +- /devices -- list active WS connections (device_id, connected_at) +- /kick -- revoke specific device +- Max 5 concurrent device sessions +- /devices/revoke-all API endpoint = panic button (kills all except current) + +## Security + +- Seed = 24-word BIP39 mnemonic = master key. Derives Ed25519 identity + ETH wallet. +- NEVER share seed. Only way to recover account. +- X3DH key exchange establishes sessions. Double Ratchet provides forward secrecy. +- All DMs E2E encrypted. Group msgs encrypted per-member. +- Server sees: metadata (who talks to whom, timestamps), encrypted blobs, presence. +- Server cannot read msg content. +- Pre-keys: signed pre-key + 10 one-time pre-keys uploaded on register. +- Bot API msgs are NOT E2E encrypted (plaintext JSON envelopes). + +## Federation + +- 2 servers connected via persistent WebSocket +- Config: JSON file with server_id, shared_secret, peer URL +- Messages auto-route across servers (srv checks remote presence) +- Aliases globally unique across federation +- @alias resolution checks local first, then federated peer +- Same client commands work regardless of which srv peer is on +- Auto-reconnects on connection failure + +## Web Client + +- Browser access at server root URL (/) +- WASM-compiled client, same crypto as TUI +- PWA: installable, offline-capable (service worker caches shell) +- Same E2E encryption as native client +- Deep links: navigate to specific peers/groups via URL + +## Troubleshooting + +Problem | Cause | Fix +--- | --- | --- +"peer not registered" | recipient hasn't run register yet | they need to `warzone register` +"session reset" | crypto session re-established | normal after key rotation or recovery, msgs continue +"connection lost" | WS disconnected | auto-reconnects, no action needed +"alias already taken" | someone else has it | pick different name or wait for 365d expiry + 30d grace +"not a member" | sending to group you left | /gjoin first +"invalid token" | bot token expired or wrong | re-register bot +"file too large" | over 10MB | split file manually +no prekeys available | recipient's one-time prekeys exhausted | they need to re-register or come online + +## Bot API + +Telegram-compatible REST API. Base: /v1/ + +Endpoint | Method | Body | Returns +--- | --- | --- | --- +/bot/register | POST | {"name":"mybot","fingerprint":"abc..."} | {"token":"...","name":"..."} +/bot/:token/getMe | GET | -- | bot info +/bot/:token/getUpdates | POST | {"timeout":5} | array of Update objects +/bot/:token/sendMessage | POST | {"chat_id":"","text":"hello"} | msg confirmation + +- Token format: fp_prefix:random_hex +- getUpdates: long-poll (max 5s), returns then deletes queued msgs +- sendMessage: plaintext JSON, NOT E2E encrypted +- Updates include: messages, key exchanges, call signals, file headers +- Bot msgs delivered via same routing (WS push or DB queue) + +## Server API (other endpoints) + +- POST /v1/register -- upload prekey bundle +- GET /v1/keys/:fp -- fetch prekeys for peer +- POST /v1/send -- send encrypted msg +- GET /v1/receive/:fp -- poll msgs (WS preferred) +- WS /v1/ws?fp=&token= -- real-time connection +- GET /v1/presence/:fp -- check online status +- GET/POST /v1/friends -- encrypted friend list +- GET /v1/devices -- list sessions +- POST /v1/devices/:id/kick -- kick device +- Alias routes under /v1/alias/* +- Group routes under /v1/groups/* diff --git a/warzone/docs/SERVER.md b/warzone/docs/SERVER.md index 36a6077..6d0d712 100644 --- a/warzone/docs/SERVER.md +++ b/warzone/docs/SERVER.md @@ -1,37 +1,63 @@ -# Warzone Server -- Operation & Administration +# Warzone Server -- Administration Guide + +**Version 0.0.21** --- ## 1. Building -The server is part of the Cargo workspace. From the workspace root: +### Local Build + +From the workspace root: ```bash -# Debug build +# Debug cargo build -p warzone-server -# Release build (recommended for deployment) +# Release (recommended for deployment) cargo build -p warzone-server --release ``` -The resulting binary is at `target/release/warzone-server` (or -`target/debug/warzone-server`). It is a single statically-linked binary with -no runtime dependencies beyond libc. +Binary output: `target/release/warzone-server`. + +### Cross-Compile for Linux (x86_64) + +The `scripts/build-linux.sh` script spins up a Hetzner Cloud VPS, builds +Linux release binaries, and pulls them back to `target/linux-x86_64/`. + +```bash +# Full pipeline: build + deploy to all production servers + destroy VM +./scripts/build-linux.sh --ship + +# Step-by-step: +./scripts/build-linux.sh --prepare # create VM, install deps, upload source +./scripts/build-linux.sh --build # compile release binaries on the VM +./scripts/build-linux.sh --transfer # download binaries to target/linux-x86_64/ +./scripts/build-linux.sh --destroy # delete the VM + +# Or all three build steps at once (VM persists): +./scripts/build-linux.sh --all +``` ### Minimum Rust Version -Rust 1.75 or later (set via `rust-version = "1.75"` in `Cargo.toml`). +Rust 1.75 or later (`rust-version = "1.75"` in `Cargo.toml`). --- ## 2. Running +### Basic + ```bash -# Default: bind 0.0.0.0:7700, data in ./warzone-data +# Defaults: bind 0.0.0.0:7700, data in ./warzone-data ./warzone-server # Custom bind address and data directory -./warzone-server --bind 127.0.0.1:8080 --data-dir /var/lib/warzone +./warzone-server --bind 0.0.0.0:7700 --data-dir ./data + +# With federation enabled +./warzone-server --federation federation.json ``` ### CLI Flags @@ -39,214 +65,339 @@ Rust 1.75 or later (set via `rust-version = "1.75"` in `Cargo.toml`). | Flag | Short | Default | Description | |------|-------|---------|-------------| | `--bind` | `-b` | `0.0.0.0:7700` | Address and port to listen on | -| `--data-dir` | `-d` | `./warzone-data` | Directory for sled database files | +| `--data-dir` | `-d` | `./warzone-data` | Directory for the sled database | +| `--federation` | `-f` | *(none)* | Path to federation JSON config file | -### Logging +### Environment Variables -The server uses `tracing-subscriber`. Control log level with the `RUST_LOG` -environment variable: +| Variable | Default | Description | +|----------|---------|-------------| +| `RUST_LOG` | `warn` (production) | Log filter. Examples: `info`, `warzone_server=debug`, `trace` | +| `WZP_RELAY_ADDR` | *(none)* | WZP voice relay address advertised to clients | + +### systemd Service + +A production-ready unit file is provided at `deploy/warzone-server.service`: + +```ini +[Unit] +Description=Warzone Messenger Server (featherChat) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=warzone +Group=warzone +WorkingDirectory=/home/warzone +ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json +Restart=always +RestartSec=3 +LimitNOFILE=65536 + +# Security hardening +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/warzone/data +PrivateTmp=yes + +Environment=RUST_LOG=warn,warzone_server::federation=info + +[Install] +WantedBy=multi-user.target +``` + +Install and enable: ```bash -RUST_LOG=info ./warzone-server -RUST_LOG=warzone_server=debug ./warzone-server -RUST_LOG=trace ./warzone-server # very verbose +sudo cp deploy/warzone-server.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now warzone-server ``` --- -## 3. API Reference +## 3. Configuration -All API endpoints are under the `/v1` prefix. The web UI is served at `/`. +### Federation JSON -### Health Check +Enable federation by passing `--federation ` on startup. The config +file specifies the local server identity, peer connection details, and a +shared secret for authentication. -``` -GET /v1/health -``` +**Format** (see `federation.example.json`): -**Response:** ```json { - "status": "ok", - "version": "0.1.0" + "server_id": "alpha", + "shared_secret": "change-me-to-a-long-random-string-shared-between-both-servers", + "peer": { + "id": "bravo", + "url": "http://10.0.0.2:7700" + }, + "presence_interval_secs": 5 } ``` -Use this for monitoring, load balancer health probes, and uptime checks. +| Field | Description | +|-------|-------------| +| `server_id` | Unique name for this server (e.g. `"alpha"`) | +| `shared_secret` | Pre-shared secret; must match on both sides | +| `peer.id` | The remote server's `server_id` | +| `peer.url` | HTTP base URL of the remote server | +| `presence_interval_secs` | How often to broadcast online-user lists (default 5) | --- -### Register Key Bundle +## 4. Federation -``` -POST /v1/keys/register -Content-Type: application/json -``` +Federation connects two Warzone servers over a persistent WebSocket so +their users can communicate transparently. + +### How It Works + +- On startup, each server opens an outgoing WebSocket to its peer at + `/v1/federation/ws` and authenticates with the shared secret. +- The connection auto-reconnects on failure. +- Presence (online fingerprints) is synced on the configured interval. +- Messages to users on the remote server are forwarded automatically. + +### Federated Features + +| Feature | Behavior | +|---------|----------| +| **Key lookup proxy** | If a key bundle is not found locally, the server queries the peer | +| **Message forwarding** | Messages addressed to a remote fingerprint are relayed over the WS | +| **Alias resolution** | `/v1/resolve/:address` checks the peer if the alias is not local | +| **Presence sync** | Each server broadcasts its online fingerprints to the peer | + +### Two-Server Setup + +**Server A** (`alpha`, e.g. `mequ`): -**Request body:** ```json { - "fingerprint": "a3f8:c912:44be:7d01", - "bundle": [/* bincode-serialized PreKeyBundle as byte array */] + "server_id": "alpha", + "shared_secret": "s3cret-shared-between-both", + "peer": { "id": "bravo", "url": "http://bravo-host:7700" }, + "presence_interval_secs": 5 } ``` -The `bundle` field is a JSON array of unsigned bytes (the raw bincode -serialization of a `PreKeyBundle`). +**Server B** (`bravo`, e.g. `kh3rad3ree`): -**Response:** ```json { - "ok": true + "server_id": "bravo", + "shared_secret": "s3cret-shared-between-both", + "peer": { "id": "alpha", "url": "http://alpha-host:7700" }, + "presence_interval_secs": 5 } ``` -**Behavior:** stores the bundle in the `keys` sled tree, keyed by the -fingerprint string. Overwrites any existing bundle for the same fingerprint. +Both files use the same `shared_secret`. Each server's `peer.id` matches +the other server's `server_id`. + +### Federation Status Endpoint + +```bash +curl http://localhost:7700/v1/federation/status +``` + +Returns JSON with connection state, peer info, and presence data. --- -### Fetch Key Bundle +## 5. API Reference -``` -GET /v1/keys/{fingerprint} -``` +All endpoints are prefixed with `/v1`. The web UI is served at `/`. -**Path parameter:** the fingerprint string, e.g. `a3f8:c912:44be:7d01`. +### Notation -**Response (200):** -```json -{ - "fingerprint": "a3f8:c912:44be:7d01", - "bundle": "base64-encoded-bincode-bytes..." -} -``` - -The `bundle` value is standard base64-encoded bincode. The client decodes -base64, then deserializes with bincode to recover the `PreKeyBundle`. - -**Response (404):** returned if no bundle is registered for the fingerprint. +- **Auth** = requires `Authorization: Bearer ` header (write routes). +- **Public** = no authentication needed (read routes). --- -### Send Message +### Health -``` -POST /v1/messages/send -Content-Type: application/json -``` - -**Request body:** -```json -{ - "to": "b7d1:e845:0022:9f3a", - "message": [/* bincode-serialized WireMessage as byte array */] -} -``` - -**Response:** -```json -{ - "ok": true -} -``` - -**Behavior:** the message bytes are stored in the `messages` sled tree under -the key `queue:{recipient_fingerprint}:{uuid}`. The UUID is generated -server-side to ensure unique keys. - -The server does NOT parse, validate, or inspect the message contents. It is an -opaque blob. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/health` | Public | Health check; returns `{"status":"ok"}` | --- -### Poll Messages +### Keys -``` -GET /v1/messages/poll/{fingerprint} -``` - -**Response (200):** -```json -[ - "base64-encoded-message-1", - "base64-encoded-message-2" -] -``` - -Returns a JSON array of base64-encoded message blobs. Each blob is a -bincode-serialized `WireMessage`. An empty array means no messages. - -**Behavior:** scans the `messages` sled tree for all keys prefixed with -`queue:{fingerprint}`. Messages are NOT deleted by polling; they remain until -explicitly acknowledged. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/keys/register` | Public | Register a pre-key bundle | +| POST | `/v1/keys/replenish` | Public | Upload additional one-time pre-keys | +| GET | `/v1/keys/:fingerprint` | Public | Fetch a key bundle (falls back to federation peer) | +| GET | `/v1/keys/list` | Public | List all registered fingerprints | +| GET | `/v1/keys/:fingerprint/otpk-count` | Public | Remaining one-time pre-key count | +| GET | `/v1/keys/:fingerprint/devices` | Public | List devices for a fingerprint | --- -### Acknowledge Message +### Messages -``` -DELETE /v1/messages/{id}/ack -``` - -**Path parameter:** the message storage key (currently the full sled key -including the `queue:` prefix and UUID). - -**Response:** -```json -{ - "ok": true -} -``` - -**Behavior:** removes the message from the `messages` tree. - -**Note:** the current implementation requires knowing the exact sled key to -acknowledge. A proper message-ID-based index is planned for Phase 2. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/messages/send` | Auth | Send an encrypted message blob | +| GET | `/v1/messages/poll/:fingerprint` | Public | Poll queued messages | +| DELETE | `/v1/messages/:id/ack` | Public | Acknowledge (delete) a message | --- -## 4. Web UI +### Groups -The server serves a single-page web client at the root path `/`. - -``` -GET / -``` - -Returns an HTML page with embedded CSS and JavaScript. The web client provides: - -- **Identity generation:** generates a random 32-byte seed in the browser - using `crypto.getRandomValues()`. -- **Identity recovery:** paste a hex-encoded seed to recover. -- **Fingerprint display:** shows the user's fingerprint in the header. -- **Key registration:** automatically registers a public key with the server - on entry. -- **Message polling:** polls `/v1/messages/poll/{fingerprint}` every 5 seconds. -- **Slash commands:** `/help`, `/info`, `/seed`. - -### Web Client Limitations - -- Uses ECDH P-256 (Web Crypto API) instead of X25519. Cross-client - compatibility with the CLI is not yet implemented. (Phase 2) -- Does not use BIP39 mnemonics; seed is displayed as hex. -- Message decryption is not yet wired (Double Ratchet in JS is TODO). -- The seed is stored in `localStorage` (unencrypted). +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/groups/create` | Auth | Create a group | +| POST | `/v1/groups/:name/join` | Auth | Join a group | +| POST | `/v1/groups/:name/send` | Auth | Send a message to a group | +| POST | `/v1/groups/:name/leave` | Auth | Leave a group | +| POST | `/v1/groups/:name/kick` | Auth | Kick a member from a group | +| GET | `/v1/groups` | Public | List all groups | +| GET | `/v1/groups/:name` | Public | Get group details | +| GET | `/v1/groups/:name/members` | Public | List members (includes online status) | --- -## 5. Database +### Aliases -The server uses **sled** (embedded key-value store). All data lives under the -directory specified by `--data-dir`. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/alias/register` | Auth | Register a human-readable alias | +| POST | `/v1/alias/unregister` | Auth | Remove your alias | +| POST | `/v1/alias/recover` | Auth | Transfer alias to a new fingerprint | +| POST | `/v1/alias/renew` | Auth | Renew alias expiry | +| POST | `/v1/alias/admin-remove` | Auth | Admin-remove an alias | +| GET | `/v1/alias/resolve/:name` | Public | Resolve alias to fingerprint | +| GET | `/v1/alias/list` | Public | List all registered aliases | +| GET | `/v1/alias/whois/:fingerprint` | Public | Reverse-lookup: fingerprint to alias | -### Trees (Tables) +--- -| Tree | Key format | Value | Purpose | -|------|-----------|-------|---------| -| `keys` | fingerprint string (UTF-8 bytes) | bincode `PreKeyBundle` | Pre-key bundle storage | -| `messages` | `queue:{fingerprint}:{uuid}` (UTF-8 bytes) | bincode `WireMessage` | Message queue | -| `otpks` | (reserved) | (reserved) | One-time pre-key tracking (not yet used server-side) | +### Calls (WZP) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/calls/initiate` | Auth | Start a 1:1 call | +| POST | `/v1/calls/:id/end` | Auth | End an active call | +| POST | `/v1/calls/missed` | Auth | Get missed calls for a fingerprint | +| POST | `/v1/groups/:name/call` | Auth | Initiate a group call | +| GET | `/v1/calls/:id` | Public | Get call details | +| GET | `/v1/calls/active` | Public | List active calls | + +--- + +### Devices + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/devices/:id/kick` | Auth | Disconnect a specific device | +| POST | `/v1/devices/revoke-all` | Auth | Disconnect all devices (optional keep one) | +| GET | `/v1/devices` | Auth | List your connected devices | + +--- + +### Presence + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/presence/batch` | Auth | Batch-query presence for multiple fingerprints | +| GET | `/v1/presence/:fingerprint` | Public | Check if a fingerprint is online | + +--- + +### Friends + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/friends` | Auth | Save friend list (encrypted blob) | +| GET | `/v1/friends` | Auth | Retrieve saved friend list | + +--- + +### Resolve + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/resolve/:address` | Public | Universal resolve: ETH address, alias, or fingerprint. Checks federation peer if not found locally. | + +--- + +### WZP Voice Relay + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/wzp/relay-config` | Public | Get the WZP relay address for voice calls | + +--- + +### Federation + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/federation/status` | Public | Federation connection status and peer info | +| GET | `/v1/federation/ws` | Internal | WebSocket endpoint for server-to-server communication | + +--- + +### Bot API + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/bot/register` | Auth | Register a bot; returns an API token | +| GET | `/v1/bot/:token/getMe` | Token | Bot identity info | +| POST | `/v1/bot/:token/getUpdates` | Token | Long-poll for new messages (Telegram-compatible) | +| POST | `/v1/bot/:token/sendMessage` | Token | Send a message as the bot (Telegram-compatible) | + +Bot tokens are scoped to the bot's fingerprint. The `getUpdates` and +`sendMessage` endpoints follow the Telegram Bot API conventions so existing +Telegram bot libraries can be adapted with minimal changes. + +--- + +### WebSocket + +| Path | Description | +|------|-------------| +| `/v1/ws/:fingerprint` | Real-time message delivery. Clients receive instant push of new messages. | + +--- + +### Web UI + +| Path | Description | +|------|-------------| +| `/` | Single-page WASM web client | +| `/wasm/warzone_wasm.js` | WASM JavaScript bindings | +| `/wasm/warzone_wasm_bg.wasm` | WASM binary | + +--- + +## 6. Database + +The server uses **sled** (embedded key-value store). All data lives under +the `--data-dir` directory. + +### Trees + +| Tree | Purpose | +|------|---------| +| `keys` | Pre-key bundles (public keys only) | +| `messages` | Queued encrypted message blobs | +| `groups` | Group metadata and membership | +| `aliases` | Human-readable alias mappings | +| `tokens` | Authentication tokens (device sessions) | +| `calls` | Call records (1:1 and group) | +| `missed_calls` | Missed call notifications | +| `friends` | Encrypted friend lists | +| `eth_addresses` | Ethereum address to fingerprint mappings | ### Data Directory Structure @@ -254,161 +405,123 @@ directory specified by `--data-dir`. warzone-data/ db # sled database file conf # sled config - blobs/ # sled blob storage (if any) + blobs/ # sled blob storage snap.*/ # sled snapshots ``` -The exact file layout is managed by sled internally. The entire directory -should be treated as a unit for backup. - -### What the Server Stores - -- **Pre-key bundles:** public keys only. The server never holds private keys. -- **Encrypted message blobs:** opaque binary data. The server cannot read - message contents. -- **Metadata visible to server:** sender fingerprint, recipient fingerprint, - message size, timestamps (implicit from storage order). +The entire directory should be treated as a unit for backup. Stop the server +before copying, or use filesystem-level snapshots (LVM, ZFS, btrfs). --- -## 6. Deployment +## 7. Security -### Single Binary +### Auth Middleware -The recommended deployment is a single `warzone-server` binary behind a -reverse proxy for TLS termination. +All write (POST) endpoints require a bearer token in the `Authorization` +header. Tokens are issued during key registration and tied to a fingerprint. +Read (GET) endpoints are public. -### Reverse Proxy (nginx) +### Rate Limiting -```nginx -server { - listen 443 ssl http2; - server_name wz.example.com; +- **200 concurrent requests** (tower `ConcurrencyLimitLayer`) +- **5 WebSocket connections per fingerprint** (multi-device cap) - ssl_certificate /etc/letsencrypt/live/wz.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/wz.example.com/privkey.pem; +### Device Management - location / { - proxy_pass http://127.0.0.1:7700; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; +Users can list connected devices, kick individual devices, or revoke all +sessions via the `/v1/devices` endpoints. The `revoke-all` endpoint accepts +an optional `keep_device_id` to keep the current device active. - # WebSocket support (for future real-time push) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } -} -``` +### What the Server Can See -When using a reverse proxy, bind the server to localhost only: +| Data | Visible | +|------|---------| +| Message plaintext | No (E2E encrypted blobs) | +| Sender/recipient fingerprints | Yes | +| Message size and timing | Yes | +| Public pre-key bundles | Yes (public by design) | +| IP addresses | Yes (from HTTP) | + +--- + +## 8. Monitoring + +### Logging + +Control verbosity with `RUST_LOG`: ```bash -./warzone-server --bind 127.0.0.1:7700 +RUST_LOG=warn ./warzone-server # production default +RUST_LOG=info ./warzone-server # request-level logging +RUST_LOG=warzone_server=debug ./warzone-server # server internals +RUST_LOG=trace ./warzone-server # everything ``` -### systemd Service +With systemd: -```ini -[Unit] -Description=Warzone Messenger Server -After=network.target - -[Service] -Type=simple -User=warzone -ExecStart=/usr/local/bin/warzone-server --bind 127.0.0.1:7700 --data-dir /var/lib/warzone -Restart=always -RestartSec=5 -Environment=RUST_LOG=info - -[Install] -WantedBy=multi-user.target +```bash +journalctl -u warzone-server -f ``` ---- - -## 7. Monitoring - -### Health Endpoint +### Health Check ```bash curl http://localhost:7700/v1/health -# {"status":"ok","version":"0.1.0"} ``` -Use this for: -- Load balancer health checks -- Uptime monitoring (e.g., with `uptime-kuma`, Prometheus blackbox exporter) -- Deployment verification +### Federation Status -### Logs +```bash +curl http://localhost:7700/v1/federation/status +``` -All request activity is logged via `tracing`. In production, pipe to a log -aggregator or use `journalctl -u warzone-server`. +Returns connection state, peer identity, and synced presence data. --- -## 8. Security Considerations +## 9. Deploy Scripts -### The Server Is a Dumb Relay +The `scripts/build-linux.sh` script handles the full build and deploy +lifecycle via Hetzner Cloud VMs. -The server never sees plaintext message content. It stores and forwards -opaque encrypted blobs. Even if the server is fully compromised, an attacker -gains: +### Key Commands -- **Encrypted message blobs** (useless without recipient's private keys) -- **Public pre-key bundles** (public by design) -- **Metadata:** who is messaging whom, when, and how often +| Command | Description | +|---------|-------------| +| `--ship` | Full pipeline: build on VM, deploy to all production servers, destroy VM | +| `--update-all` | Upload pre-built binaries to all production servers and restart | +| `--update ` | Update a single production server | +| `--status` | Check service status and federation on all production servers | +| `--logs [user@host]` | Tail `journalctl` logs (defaults to first production server) | -### What the Server CAN See +### Typical Deploy Workflow -| Data | Visible to server | -|------|-------------------| -| Message plaintext | No | -| Sender fingerprint | Yes (in `WireMessage`) | -| Recipient fingerprint | Yes (used for routing) | -| Message size | Yes | -| Timing | Yes | -| IP addresses | Yes (from HTTP) | -| Pre-key bundles (public keys) | Yes | +```bash +# One command: build, deploy everywhere, clean up +./scripts/build-linux.sh --ship -### Mitigations for Metadata (Future) - -- **Sealed sender** (Phase 6): hide sender identity from the server. -- **Padding:** fixed-size messages to prevent size-based analysis. -- **Onion routing** (Phase 6): hide IP addresses via relay chains. - -### Access Control - -The current server has **no authentication**. Anyone can: -- Register a key bundle for any fingerprint -- Poll messages for any fingerprint -- Send messages to any fingerprint - -**TODO (Phase 2):** authentication via Ed25519 challenge-response. Clients -sign requests to prove they own the fingerprint they claim. +# Or step by step: +./scripts/build-linux.sh --all # build (VM persists) +./scripts/build-linux.sh --update-all # deploy binaries +./scripts/build-linux.sh --destroy # clean up VM +./scripts/build-linux.sh --status # verify +``` --- -## 9. Backup and Recovery +## 10. Backup and Recovery -### Database Backup - -The sled database can be backed up by copying the entire data directory while -the server is stopped: +### Backup ```bash systemctl stop warzone-server -cp -r /var/lib/warzone /backup/warzone-$(date +%Y%m%d) +cp -r /home/warzone/data /backup/warzone-$(date +%Y%m%d) systemctl start warzone-server ``` -**Warning:** copying the sled directory while the server is running may -produce an inconsistent snapshot. Stop the server first or use filesystem-level -snapshots (LVM, ZFS, btrfs). +Do not copy the sled directory while the server is running without +filesystem-level snapshots. ### Recovery @@ -416,14 +529,5 @@ snapshots (LVM, ZFS, btrfs). 2. Replace the data directory with the backup. 3. Start the server. -Messages queued after the backup was taken will be lost. Since all messages -are E2E encrypted, there is no way to recover them from any other source. - -### Data Loss Impact - -- **Lost key bundles:** users must re-register. No security impact (public - data). -- **Lost message queue:** undelivered messages are permanently lost. Senders - will not know delivery failed (no delivery receipts yet). -- **Corrupted database:** sled includes crash recovery. If the database is - corrupt beyond recovery, delete it and start fresh. Users re-register. +Messages queued after the backup was taken are permanently lost. All +messages are E2E encrypted and cannot be recovered from any other source. diff --git a/warzone/docs/USAGE.md b/warzone/docs/USAGE.md index 6917315..c872424 100644 --- a/warzone/docs/USAGE.md +++ b/warzone/docs/USAGE.md @@ -1,6 +1,6 @@ -# Warzone Messenger (featherChat) — Usage Guide +# featherChat Usage Guide -**Version:** 0.0.20 +**Version:** 0.0.21 --- @@ -11,540 +11,390 @@ Requirements: Rust 1.75+, cargo ```bash -# Clone the repository git clone cd warzone -# Build all binaries cargo build --release -# Binaries are in target/release/ -# warzone-server — server -# warzone-client — CLI/TUI client +# Binaries output to target/release/: +# warzone-server — relay server (with embedded web client) +# warzone-client — CLI / TUI client ``` -### Build the WASM Module (Web Client) +### WASM Build (Web Client) Requirements: wasm-pack ```bash cd crates/warzone-wasm wasm-pack build --target web -# Output in pkg/ — copy to web client directory +# Output in pkg/ — copy to the web client directory +``` + +### Linux Cross-Compile + +The `scripts/build-linux.sh` script builds Linux x86_64 binaries on a Hetzner Cloud VPS. + +```bash +# Full pipeline: create VM, build, download binaries +./scripts/build-linux.sh --all + +# Or step by step: +./scripts/build-linux.sh --prepare # Create VM, install deps, upload source +./scripts/build-linux.sh --build # Build release binaries on the VM +./scripts/build-linux.sh --transfer # Download binaries to target/linux-x86_64/ +./scripts/build-linux.sh --destroy # Delete the VM + +# One-command ship to all production servers: +./scripts/build-linux.sh --ship # prepare + build + transfer + deploy + destroy ``` ### Server Configuration -The server accepts two flags: - ```bash warzone-server --bind 0.0.0.0:7700 --data-dir ./warzone-data ``` -| Flag | Default | Description | -|--------------|-------------------|-----------------------| -| `--bind` | `0.0.0.0:7700` | Listen address | -| `--data-dir` | `./warzone-data` | sled database path | +| Flag | Default | Description | +|--------------|------------------|--------------------| +| `--bind` | `0.0.0.0:7700` | Listen address | +| `--data-dir` | `./warzone-data` | sled database path | Environment variables: -| Variable | Default | Description | -|-------------------------|----------|----------------------------| -| `WARZONE_ADMIN_PASSWORD`| `admin` | Password for admin alias ops| -| `RUST_LOG` | `info` | Log level filter | +| Variable | Default | Description | +|--------------------------|---------|------------------------------| +| `WARZONE_ADMIN_PASSWORD` | `admin` | Password for admin alias ops | +| `RUST_LOG` | `info` | Log level filter | --- -## Quick Start +## Identity -### CLI Quick Start +### Generate a New Identity ```bash -# 1. Generate a new identity -warzone init -# → Prompts for passphrase -# → Displays fingerprint and 24-word mnemonic -# → SAVE THE MNEMONIC — it is your identity - -# 2. Register with a server -warzone register --server http://your-server:7700 - -# 3. Show your identity -warzone info -# Fingerprint: a3f8:c912:44be:7d01:... -# Signing key: ... -# Encryption key: ... - -# 4. Send a message -warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://your-server:7700 - -# 5. Receive messages -warzone recv --server http://your-server:7700 - -# 6. Launch interactive TUI -warzone chat --server http://your-server:7700 -warzone chat a3f8:c912:44be:7d01:... --server http://your-server:7700 -warzone chat @alice --server http://your-server:7700 -``` - -### Web Client Quick Start - -1. Navigate to the server URL in a browser (e.g., `http://your-server:7700`) -2. The web client generates a new identity automatically on first visit -3. Your seed is stored in `localStorage` — back it up via the displayed hex seed -4. Use `/peer ` or `/peer @alias` to select a chat partner -5. Type messages and press Enter to send - ---- - -## CLI Commands - -### `warzone init` - -Generate a new identity (seed + keypair + pre-keys). - -```bash -$ warzone init -Set passphrase (empty for no encryption): **** -Confirm passphrase: **** - -Your identity: - Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 - Mnemonic: abandon ability able about above absent absorb abstract ... - -SAVE YOUR MNEMONIC — it is the ONLY way to recover your identity. +warzone-client init +# Prompts for passphrase +# Displays fingerprint + 24-word BIP39 mnemonic +# SAVE THE MNEMONIC — it is the only way to recover your identity ``` The seed is stored at `~/.warzone/identity.seed`, encrypted with Argon2id + ChaCha20-Poly1305. -### `warzone recover ` - -Recover an identity from a BIP39 mnemonic. +### Recover from Mnemonic ```bash -$ warzone recover abandon ability able about above absent absorb abstract ... -Set passphrase (empty for no encryption): **** -Confirm passphrase: **** -Identity recovered. Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 +warzone-client recover abandon ability able about above absent absorb abstract ... +# Prompts for passphrase, restores the same identity ``` -### `warzone info` - -Display your fingerprint and public keys. +### View Your Identity ```bash -$ warzone info -Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 -Signing key: 3a7b... -Encryption key: 9f2c... +warzone-client info +# Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 ``` -### `warzone eth` +In the TUI, use `/info` to display your fingerprint, `/seed` to display your 24-word recovery mnemonic, and `/eth` to display your Ethereum address. -Display your Ethereum-compatible address derived from the same seed. +### Ethereum Address + +Your ETH address is derived from the same seed via domain-separated HKDF. One seed, dual identity. ```bash -$ warzone eth -Warzone fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 -Ethereum address: 0x71C7656EC7ab88b098defB751B7401B5f6d8976F -Same seed, dual identity. +warzone-client eth +# Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 +# Ethereum: 0x71C7656EC7ab88b098defB751B7401B5f6d8976F ``` -### `warzone register` +### Addressing -Register your pre-key bundle with a server. +featherChat supports three addressing modes. All three work anywhere a peer address is accepted: -```bash -$ warzone register --server http://localhost:7700 -``` - -### `warzone send ` - -Send an encrypted message. Recipient can be a fingerprint or `@alias`. - -```bash -$ warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://localhost:7700 -$ warzone send @alice "Hello!" --server http://localhost:7700 -``` - -### `warzone recv` - -Poll the server for messages and decrypt them. - -```bash -$ warzone recv --server http://localhost:7700 -``` - -### `warzone chat [peer]` - -Launch the interactive TUI client. - -```bash -$ warzone chat --server http://localhost:7700 -$ warzone chat @alice --server http://localhost:7700 -$ warzone chat a3f8:c912:... --server http://localhost:7700 -``` - -### `warzone backup [output]` - -Export an encrypted backup of local data (sessions, pre-keys). - -```bash -$ warzone backup my-backup.wzb -Backup saved to my-backup.wzb (4096 bytes encrypted) -``` - -The backup is encrypted with your seed via HKDF(info="warzone-history") + ChaCha20-Poly1305. - -### `warzone restore ` - -Restore from an encrypted backup. Requires the same seed. - -```bash -$ warzone restore my-backup.wzb -Restored 12 entries from my-backup.wzb -``` +| Format | Example | Description | +|--------|---------|-------------| +| Fingerprint | `a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4` | SHA-256 of Ed25519 pubkey, 8 groups of 4 hex digits | +| Alias | `@alice` | Human-readable, server-resolved | +| ETH address | `0x71C7...976F` | Ethereum address derived from the same seed | --- -## TUI Commands +## TUI Client -The TUI is launched with `warzone chat`. All commands start with `/`. +Launch the interactive terminal UI: -### Peer & Navigation +```bash +warzone-client chat --server http://your-server:7700 +warzone-client chat @alice --server http://your-server:7700 +warzone-client chat a3f8:c912:... --server http://your-server:7700 +``` -| Command | Short | Description | -|------------------------|-------|----------------------------------------------| -| `/peer ` | `/p` | Set the active peer (fingerprint or @alias) | -| `/dm` | | Switch to DM mode (clear group context) | -| `/r` or `/reply` | | Switch to last person who DM'd you | -| `/info` | | Display your fingerprint | -| `/eth` | | Display your Ethereum address | -| `/quit` | `/q` | Exit the TUI | +### Complete Command Reference + +#### Peer and Navigation + +| Command | Description | +|---------|-------------| +| `/peer ` | Set DM peer by fingerprint | +| `/p @alias` | Set DM peer by alias (short form of `/peer`) | +| `/peer 0x...` | Set DM peer by ETH address | +| `/r [message]` | Reply to last DM sender; optionally include an inline message | +| `/dm` | Switch to DM mode (clear group context) | ``` /peer a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 /p @alice +/peer 0x71C7656EC7ab88b098defB751B7401B5f6d8976F /r +/r hey, got your message /dm ``` -### Alias Management +#### Groups -| Command | Description | -|-----------------------|--------------------------------------------| -| `/alias ` | Register an alias for your fingerprint | -| `/unalias` | Remove your alias | -| `/aliases` | List all registered aliases | - -``` -/alias alice -/unalias -/aliases -``` - -When you register an alias, the server returns a **recovery key** (32 hex chars). Save it — it is the only way to reclaim the alias if you lose access to your identity. - -### Contacts & History - -| Command | Short | Description | -|------------------------|-------|------------------------------------------| -| `/contacts` | `/c` | List all contacts with message counts | -| `/history [peer]` | `/h` | Show message history (last 50 messages) | - -``` -/contacts -/c -/history a3f8c91244be7d01 -/h -``` - -If a peer is already set, `/h` without arguments shows that peer's history. - -### Group Commands - -| Command | Description | -|------------------------|------------------------------------------| -| `/g ` | Switch to group (auto-join) | -| `/gcreate ` | Create a new group | -| `/gjoin ` | Join an existing group | -| `/gleave` | Leave the current group | -| `/gkick ` | Kick a member (creator only) | -| `/gmembers` | List members of the current group | -| `/glist` | List all groups on the server | +| Command | Description | +|---------|-------------| +| `/g ` | Switch to group (auto-joins if not a member) | +| `/gcreate ` | Create a new group (you become creator) | +| `/gjoin ` | Join an existing group | +| `/gleave` | Leave the current group | +| `/gkick ` | Kick a member (creator only) | +| `/gmembers` | List members of the current group with online status | +| `/glist` | List all groups on the server | ``` /gcreate ops-team /g ops-team -/gjoin ops-team /gmembers -/gkick a3f8c91244be7d01 +/gkick @mallory /gleave /glist ``` -Group messages are prefixed with `#groupname` in the UI. The current target shows in the header bar. +When in a group, the header bar shows `#groupname` and all messages are sent to that group. -### File Transfer +#### Alias Management -| Command | Description | -|-------------------|----------------------------------------------| -| `/file ` | Send a file to the current peer or group | +| Command | Description | +|---------|-------------| +| `/alias ` | Register an alias for your fingerprint | +| `/aliases` | List all registered aliases | +| `/unalias` | Remove your alias | + +Alias rules: 1-32 alphanumeric characters (plus `_` and `-`), case-insensitive, normalized to lowercase. TTL is 365 days of inactivity, with a 30-day grace period. Registration returns a recovery key — save it. + +#### File Transfer + +| Command | Description | +|---------|-------------| +| `/file ` | Send a file to the current peer or group | ``` /file /path/to/document.pdf /file ./photo.jpg ``` -Constraints: -- Maximum file size: 10 MB -- Chunk size: 64 KB -- Files are sent as `FileHeader` + `FileChunk` wire messages -- SHA-256 verification on receipt -- Received files are saved to the current directory +Files are split into 64 KB chunks, each encrypted with the Double Ratchet session key. The recipient reassembles and verifies a SHA-256 hash over the complete file. Maximum file size is 10 MB. Received files are saved to the current directory. -### Input Editing +#### Contacts and History -The TUI supports full readline-style editing: +| Command | Description | +|---------|-------------| +| `/contacts` or `/c` | List all contacts with message counts | +| `/history` or `/h` | Show message history for current peer (last 50) | +| `/history ` | Show history for a specific peer | -| Key | Action | -|-----------------|------------------------------| -| Left/Right | Move cursor | -| Home / Ctrl-A | Move to beginning of line | -| End / Ctrl-E | Move to end of line | -| Backspace | Delete character before cursor| -| Delete | Delete character at cursor | -| Ctrl-U | Clear entire input | -| Ctrl-W | Delete word before cursor | -| Enter | Send message / execute command| -| Ctrl-C | Quit | +#### Identity and Security + +| Command | Description | +|---------|-------------| +| `/info` | Show your fingerprint | +| `/eth` | Show your Ethereum address | +| `/seed` | Show your 24-word recovery mnemonic | +| `/devices` | List your active device sessions | +| `/kick ` | Revoke a specific device session | + +#### Friend List + +| Command | Description | +|---------|-------------| +| `/friend` | List friends with online/offline status | +| `/friend
` | Add a friend by fingerprint or alias | +| `/unfriend
` | Remove a friend | + +The friend list is end-to-end encrypted and stored on the server as an opaque blob. The server cannot read it. Presence status (online/offline) is shown next to each friend. + +#### General + +| Command | Description | +|---------|-------------| +| `/help` or `/?` | Show command list | +| `/quit` or `/q` | Exit the TUI | + +### Keyboard Navigation + +| Key | Action | +|-----|--------| +| PageUp / PageDown | Scroll messages by 10 | +| Up / Down (when input is empty) | Scroll messages by 1 | +| Ctrl+End | Snap scroll to bottom | +| Left / Right | Move cursor in input | +| Home / Ctrl-A | Beginning of line | +| End / Ctrl-E | End of line | +| Ctrl-U | Clear input | +| Ctrl-W | Delete word before cursor | +| Ctrl-C | Quit | ### Receipt Indicators -Sent messages display receipt status: - -| Indicator | Meaning | -|-----------|----------------------------| -| (tick) | Sent (no confirmation yet) | -| (double tick, gray) | Delivered (decrypted by recipient) | -| (double tick, blue) | Read (viewed by recipient) | +| Indicator | Meaning | +|-----------|---------| +| Single tick | Sent (no confirmation yet) | +| Double tick (gray) | Delivered (decrypted by recipient) | +| Double tick (blue) | Read (viewed by recipient) | --- -## Web Client Commands +## Web Client -The web client supports the same commands as the TUI, plus additional web-specific commands: +### Access -### Standard Commands (same as TUI) +Navigate to the server URL in a browser (e.g., `http://your-server:7700`). The web client generates a new identity automatically on first visit. Your seed is stored in `localStorage` — back it up using `/seed`. -`/peer`, `/p`, `/alias`, `/unalias`, `/r`, `/reply`, `/contacts`, `/c`, -`/history`, `/h`, `/g`, `/gcreate`, `/gjoin`, `/gleave`, `/gkick`, `/gmembers`, -`/glist`, `/file`, `/eth`, `/info`, `/quit`, `/dm` +The web client uses the same E2E encryption as the TUI, compiled to WASM. -### Alias Resolution +### URL Deep Links -Both TUI and web support `@alias` syntax: +The web client supports deep links for direct navigation: -``` -/peer @alice # Resolves alias to fingerprint -/p @bob # Short form -``` +| URL | Effect | +|-----|--------| +| `/message/@alice` | Opens a DM with the alias `@alice` | +| `/message/0xABC...` | Opens a DM with an ETH address | +| `/group/#ops` | Opens the group `#ops` | -### Web-Only Commands +Share these links to let someone jump straight into a conversation. -| Command | Description | -|-------------------|----------------------------------------------------| -| `/selftest` | Run WASM crypto self-test (X3DH + ratchet cycle) | -| `/bundleinfo` | Debug: show bundle details (keys, sizes) | -| `/debug` | Toggle debug mode (verbose output) | -| `/reset` | Clear identity and all local data | -| `/install` | Show PWA installation instructions | -| `/sessions` | List active ratchet sessions | -| `/admin-unalias` | Admin: remove any alias (requires admin password) | +### Clickable Addresses -### Web Client Storage +Fingerprints and addresses displayed in messages are clickable. Clicking an address sets it as your DM peer. If you are currently typing, clicking copies the address instead. -The web client stores data in `localStorage`: +### Supported Commands -| Key | Value | Purpose | -|----------------------|--------------------------------|----------------------------| -| `wz_seed` | hex seed (64 chars) | Identity seed | -| `wz_spk_secret` | hex SPK secret (64 chars) | Signed pre-key secret | -| `wz_session:` | base64 ratchet state | Per-peer session | -| `wz_contacts` | JSON contact list | Contact metadata | +The web client supports the same slash commands as the TUI: `/peer`, `/p`, `/r`, `/dm`, `/g`, `/gcreate`, `/gjoin`, `/gleave`, `/gkick`, `/gmembers`, `/glist`, `/alias`, `/aliases`, `/unalias`, `/file`, `/contacts`, `/c`, `/history`, `/h`, `/info`, `/eth`, `/seed`, `/friend`, `/unfriend`, `/devices`, `/kick`, `/help`, `/quit`. --- -## Identity Management +## Groups -### Seed - -Your identity is a 32-byte seed. All keys are deterministically derived from it. **Lose the seed = lose the identity forever.** - -### Mnemonic Backup - -The seed is displayed as a 24-word BIP39 mnemonic during `warzone init`. Write it down on paper and store securely. You can recover your full identity from the mnemonic using `warzone recover`. - -### Passphrase Encryption - -The seed file (`~/.warzone/identity.seed`) is encrypted at rest: +### Creating and Using Groups ``` -File format: WZS1(4 bytes) + salt(16) + nonce(12) + ciphertext(48) - -Encryption: Argon2id(passphrase, salt) → 32-byte key - ChaCha20-Poly1305(key, nonce, seed) → ciphertext +/gcreate ops-team # Create (you become creator) +/g ops-team # Switch to group (auto-joins if needed) +/gjoin ops-team # Explicitly join an existing group ``` -An empty passphrase stores the seed in plaintext (for testing only). - -### Ethereum Address - -Your Ethereum address is derived from the same seed with domain-separated HKDF. Use `warzone eth` or `/eth` in the TUI to display it. - -### Fingerprint Format - -Fingerprints are `SHA-256(Ed25519_pubkey)[:16]` displayed as 8 groups of 4 hex digits: - -``` -a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4 -``` - ---- - -## Alias System - -Aliases provide human-readable names for fingerprints. - -### Registration - -``` -/alias alice -``` - -Returns a **recovery key** — save it securely. One alias per fingerprint. One fingerprint per alias. - -### Rules - -- Aliases are 1-32 alphanumeric characters (plus `_` and `-`) -- Case-insensitive, normalized to lowercase -- TTL: 365 days of inactivity (auto-renewed on any message activity) -- Grace period: 30 days after expiry before reclamation -- Recovery key: allows reclaiming an expired alias - -### Recovery - -If you lose access to your identity but have the recovery key, the server provides an alias recovery endpoint. This is an HTTP API operation: - -``` -POST /v1/alias/recover -{ - "alias": "alice", - "recovery_key": "a1b2c3...", - "new_fingerprint": "new_fp_hex" -} -``` - -The recovery key is rotated on each recovery. - -### Admin Operations - -An admin (with `WARZONE_ADMIN_PASSWORD`) can remove any alias: - -``` -POST /v1/alias/admin-remove -{ - "alias": "alice", - "admin_password": "admin" -} -``` - ---- - -## Group Management - -### Creating and Joining - -``` -/gcreate ops-team # Create a group (you become creator) -/g ops-team # Switch to group (auto-joins if not a member) -/gjoin ops-team # Explicitly join -``` - -Groups auto-create on first join if they do not exist. - -### Messaging - -When the peer is set to a group (shows as `#groupname` in the header), all messages go to that group. The server fans out to all members. +Once in a group, all messages you type go to that group. The server fans out to all members. ### Membership -- Creator can kick members with `/gkick ` -- Any member can leave with `/gleave` -- `/gmembers` shows all members with their aliases (if registered) +- `/gmembers` shows all members with aliases and online status. +- The creator can kick members with `/gkick `. +- Any member can leave with `/gleave`. -### Sender Keys (Implemented in Protocol) +### Sender Keys -The protocol implements Sender Keys for efficient group encryption: - -1. Each member generates a `SenderKey` (random 32-byte chain key) -2. The key is distributed to all members via 1:1 encrypted channels (`SenderKeyDistribution`) -3. Group messages are encrypted once with the sender's key (`GroupSenderKey`) -4. On member join/leave, all members rotate their sender keys - -This provides O(1) encryption per message instead of O(N) per-member encryption. +The protocol uses Sender Keys for efficient group encryption. Each member generates a random 32-byte chain key, distributes it to all other members over 1:1 encrypted channels, and encrypts group messages with their sender key. This gives O(1) encryption cost per message instead of O(N). Sender keys are rotated on member join or leave. --- -## Multi-Device Setup +## File Transfer -### Current Support +Files are transferred end-to-end encrypted through the relay server. -The server stores per-device bundles (`device::`). Multiple WebSocket connections per fingerprint are supported — all connected devices receive messages. +1. The sender reads the file and splits it into 64 KB chunks. +2. A `FileHeader` message is sent with the filename, total size, chunk count, and SHA-256 hash. +3. Each `FileChunk` is encrypted with the Double Ratchet session and sent sequentially. +4. The recipient reassembles all chunks and verifies the SHA-256 hash. +5. The completed file is saved to the current directory. -### Setting Up a Second Device - -1. On the new device, recover from mnemonic: `warzone recover <24 words>` -2. Register with the server: `warzone register --server http://...` -3. Both devices now share the same fingerprint and receive messages - -### Limitations - -- Ratchet sessions are per-device (not synchronized between devices) -- Starting a new session on one device does not invalidate the other's session -- Encrypted backup/restore can transfer session state between devices +Maximum file size: **10 MB**. Chunk size: **64 KB**. --- -## Encrypted Backup & Restore +## Friend List -### Creating a Backup +The friend list provides presence tracking for contacts you care about. -```bash -warzone backup my-backup.wzb -``` +- `/friend
` adds a friend (by fingerprint or alias). +- `/friend` lists all friends with their current online/offline status. +- `/unfriend
` removes a friend. -This exports: -- All ratchet sessions (Double Ratchet state) -- All pre-key secrets (signed + one-time) -- Encrypted with HKDF(seed, info="warzone-history") + ChaCha20-Poly1305 +The friend list is encrypted client-side and stored on the server as an opaque blob. The server relays it but cannot read its contents. -### Restoring a Backup +--- -```bash -warzone restore my-backup.wzb -``` +## Federation -Requires the same seed (passphrase prompt). Merges data without overwriting existing entries. +Federation connects two featherChat servers so that users on different servers can message each other transparently. -### Backup File Format +### Setup -``` -WZH1(4 bytes) + nonce(12) + ciphertext +Each server needs a federation config file: -Plaintext: JSON { - "version": 1, - "sessions": { "": "base64_bincode", ... }, - "pre_keys": { "spk:1": "base64_bytes", "otpk:1": "base64_bytes", ... } +```json +{ + "server_id": "alpha", + "shared_secret": "long-random-string-shared-between-both-servers", + "peer": { + "id": "bravo", + "url": "http://10.0.0.2:7700" + }, + "presence_interval_secs": 5 } ``` + +Start the server with federation enabled: + +```bash +warzone-server --bind 0.0.0.0:7700 --federation federation.json +``` + +### How It Works + +The two servers maintain a persistent WebSocket connection between them. When a client on server Alpha sends a message to a fingerprint registered on server Bravo, server Alpha forwards the message over the federation link. The recipient's server delivers it via their normal WebSocket connection. Presence information is exchanged on a configurable interval. + +From the user's perspective, federation is transparent. You address peers the same way regardless of which server they are on. + +--- + +## Multi-Device + +### Setup + +1. On the new device, recover from mnemonic: `warzone-client recover <24 words>` +2. Register with the server: `warzone-client register --server http://...` +3. Both devices share the same fingerprint and receive messages. + +### Device Management + +- `/devices` lists all active sessions for your identity. +- `/kick ` revokes a specific device session. + +Ratchet sessions are per-device and not synchronized between devices. Use encrypted backup/restore (`warzone-client backup` / `warzone-client restore`) to transfer session state. + +--- + +## Encrypted Backup and Restore + +```bash +# Export sessions and pre-keys, encrypted with your seed +warzone-client backup my-backup.wzb + +# Restore on another device (requires same seed) +warzone-client restore my-backup.wzb +``` + +The backup contains all Double Ratchet sessions and pre-key secrets. It is encrypted with HKDF(seed, info="warzone-history") + ChaCha20-Poly1305.