feat: friend list, bot API, ETH addressing, deep links, docs overhaul

Tier 1 — New features:
- E2E encrypted friend list: server stores opaque blob (POST/GET /v1/friends),
  protocol-level encrypt/decrypt with HKDF-derived key, 4 tests
- Telegram Bot API compatibility: /bot/register, /bot/:token/getUpdates,
  sendMessage, getMe — TG-style Update objects with proper message mapping
- ETH address resolution: GET /v1/resolve/:address (0x.../alias/@.../fp),
  bidirectional ETH↔fp mapping stored on key registration
- Seed recovery: /seed command in TUI + web client
- URL deep links: /message/@alias, /message/0xABC, /group/#ops
- Group members with online status in GET /groups/:name/members

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-29 07:31:54 +04:00
parent dbf5d136cf
commit 7b72f7cba5
15 changed files with 2181 additions and 1023 deletions

View File

@@ -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 <fp>, /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 <name> Register an alias for yourself",
" /aliases List all registered aliases",
" /unalias Remove your alias",
" /friend List friends with online status",
" /friend <address> Add a friend",
" /unfriend <address> Remove a friend",
" /devices List your active device sessions",
" /kick <device_id> Kick a specific device session",
" /g <name> 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::<serde_json::Value>().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 <address> 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::<serde_json::Value>().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 <address> 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 <address>".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::<serde_json::Value>().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 <address>".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::<serde_json::Value>().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)

View File

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

View File

@@ -12,3 +12,4 @@ pub mod store;
pub mod history;
pub mod sender_keys;
pub mod ethereum;
pub mod friends;

View File

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

View File

@@ -0,0 +1,390 @@
//! Telegram Bot API compatibility layer.
//!
//! Bots register with a fingerprint and get a token.
//! They use `/bot<token>/getUpdates` and `/bot<token>/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<AppState> {
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<serde_json::Value> {
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<AppState>,
Json(req): Json<RegisterBotRequest>,
) -> AppResult<Json<serde_json::Value>> {
let fp = req
.fingerprint
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>()
.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<AppState>,
Path(token): Path<String>,
) -> Json<serde_json::Value> {
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:<bot_fp>:*` 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<AppState>,
Path(token): Path<String>,
Json(params): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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::<warzone_protocol::message::WireMessage>(&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::<serde_json::Value>(&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<AppState>,
Path(token): Path<String>,
Json(req): Json<SendMessageRequest>,
) -> Json<serde_json::Value> {
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::<String>()
.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,
}
}))
}

View File

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

View File

@@ -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<serde_json::Value> = Vec::new();
let mut online_count: usize = 0;
for fp in &group.members {
let alias = state.db.aliases.get(format!("fp:{}", fp).as_bytes())
.ok().flatten()
.map(|v| String::from_utf8_lossy(&v).to_string());
let online = state.is_online(fp).await;
if online {
online_count += 1;
}
members_info.push(serde_json::json!({
"fingerprint": fp,
"alias": alias,
"is_creator": *fp == group.creator,
"online": online,
}));
}
@@ -298,5 +304,6 @@ async fn get_members(
"name": group.name,
"members": members_info,
"count": members_info.len(),
"online_count": online_count,
})))
}

View File

@@ -46,6 +46,8 @@ struct RegisterRequest {
#[serde(default)]
device_id: Option<String>,
bundle: Vec<u8>,
#[serde(default)]
eth_address: Option<String>,
}
#[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 })
}

View File

@@ -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<AppState> {
.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)

View File

@@ -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<AppState> {
Router::new().route("/resolve/:address", get(resolve_address))
}
/// Resolve an address to a fingerprint.
///
/// Accepts: ETH address (`0x...`), alias (`@name`), or raw fingerprint.
async fn resolve_address(
State(state): State<AppState>,
Path(address): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let addr = address.trim().to_lowercase();
// ETH address: 0x...
if addr.starts_with("0x") {
if let Some(fp_bytes) = state.db.eth_addresses.get(addr.as_bytes())? {
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"type": "eth",
})));
}
// Try federation
if let Some(ref federation) = state.federation {
let url = format!("{}/v1/resolve/{}", federation.config.peer.url, addr);
if let Ok(resp) = federation.client.get(&url).send().await {
if resp.status().is_success() {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"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::<String>();
if fp.len() == 32 {
let rev_key = format!("rev:{}", fp);
let eth = state
.db
.eth_addresses
.get(rev_key.as_bytes())?
.map(|v| String::from_utf8_lossy(&v).to_string());
return Ok(Json(serde_json::json!({
"address": address,
"fingerprint": fp,
"eth_address": eth,
"type": "fingerprint",
})));
}
Ok(Json(serde_json::json!({ "error": "unrecognized address format" })))
}

View File

@@ -171,6 +171,9 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
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##"<!DOCTYPE html>
<div id="chat" class="screen">
<div id="chat-header">
<span class="tag tag-fp" id="hdr-fp"></span>
<span class="tag" id="hdr-eth" style="background:#1a1a3e;color:#4fc3f7;font-size:0.8em;cursor:pointer" title=""></span>
<span>→</span>
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
<span class="tag-server" id="hdr-server"></span>
@@ -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 '<span class="addr" data-addr="' + fp + '" title="Click to message">' + match + '</span>';
});
// Match ETH addresses: 0x followed by 40 hex chars
text = text.replace(/(0x[0-9a-fA-F]{40})/g, function(match) {
return '<span class="addr" data-addr="' + match + '" title="Click to message">' + match + '</span>';
});
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 = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
}
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + esc(from) + '</span>: ' + esc(text) + receiptHtml;
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + 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 <address> to add.');
}
} catch(e) { addSys('Error: ' + e.message); }
return;
}
if (text.startsWith('/friend ')) {
const addr = text.slice(8).trim();
if (!addr) { addSys('Usage: /friend <address>'); 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];

View File

@@ -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<Mutex<Vec<ChatLine>>>`.
---
## 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 <recipient-fingerprint> <message> -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 \<words...\>
### `warzone recover <words...>`
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 <recipient> <message>`
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": { "<fp>": "base64_bincode", ... },
"pre_keys": { "spk:1": "base64_bytes", "otpk:1": "base64_bytes", ... }
}
```
---
## 4. Identity Management
### `warzone restore <input>`
### 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 <fp>. 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 <fp_or_alias>` | `/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 <name>` | 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 <name>` | Switch to group (auto-join if needed) |
| `/gcreate <name>` | Create a new group (you become creator) |
| `/gjoin <name>` | Join an existing group |
| `/gleave` | Leave the current group |
| `/gkick <fp_or_alias>` | 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 <path>` | 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 <device_id>` | 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 <address>` | Add a friend (fingerprint or ETH address) |
| `/unfriend <address>` | 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 <address>`: the client fetches the current encrypted blob from
the server, decrypts it, adds the entry, re-encrypts, and uploads.
2. On `/unfriend <address>`: same fetch-decrypt-modify-encrypt-upload cycle.
3. On `/friend` (no argument): fetches and decrypts the blob, then checks
`/v1/presence/<fp>` 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:<fp>` | 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 <fingerprint>` command.
The server stores per-device bundles (`device:<fp>:<device_id>`). 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 <server>`.
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.

159
warzone/docs/LLM_HELP.md Normal file
View File

@@ -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 <addr>, /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 <name> | register alias for yourself | /alias alice
/aliases | list all registered aliases | /aliases
/unalias | remove your alias | /unalias
/friend | list friends + online status | /friend
/friend <addr> | add friend | /friend @bob
/unfriend <addr> | remove friend | /unfriend @bob
/devices | list active device sessions | /devices
/kick <id> | kick a device session | /kick dev_abc
/g <name> | switch to group (auto-join) | /g ops
/gcreate <name> | create group | /gcreate ops
/gjoin <name> | join group | /gjoin ops
/glist | list all groups | /glist
/gleave | leave current group | /gleave
/gkick <fp> | kick member (creator only) | /gkick abc123
/gmembers | list group members + status | /gmembers
/file <path> | 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 <fingerprint>` -- set recipient
5. Type msg, press Enter -- encrypted + sent
Recovery: `warzone recover` -- enter 24 words to restore identity on new device.
## Groups
- /gcreate <name> -- create, you become creator + first member
- /gjoin <name> -- join existing (or auto-join via /g <name>)
- 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 <fp> -- creator only, removes member
Groups auto-create on join if they don't exist. Server fans out per-member encrypted msgs.
## Files
/file <path> -- 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 <addr> -- add (fp, ETH, or @alias)
- /unfriend <addr> -- 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 <device_id> -- 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 <name> 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":"<fp>","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=<fp>&token=<tok> -- 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/*

View File

@@ -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 <path>` 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 <token>` 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 <user@host>` | 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.

View File

@@ -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 <repo-url>
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 <fingerprint>` 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 <words...>`
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 <recipient> <message>`
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 <input>`
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 <fp_or_alias>` | `/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 <fingerprint>` | 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 <name>` | 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 <name>` | Switch to group (auto-join) |
| `/gcreate <name>` | Create a new group |
| `/gjoin <name>` | Join an existing group |
| `/gleave` | Leave the current group |
| `/gkick <fp_or_alias>` | Kick a member (creator only) |
| `/gmembers` | List members of the current group |
| `/glist` | List all groups on the server |
| Command | Description |
|---------|-------------|
| `/g <name>` | Switch to group (auto-joins if not a member) |
| `/gcreate <name>` | Create a new group (you become creator) |
| `/gjoin <name>` | Join an existing group |
| `/gleave` | Leave the current group |
| `/gkick <fp_or_alias>` | 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 <path>` | Send a file to the current peer or group |
| Command | Description |
|---------|-------------|
| `/alias <name>` | 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 <path>` | 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 <fp>` | 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 <device_id>` | Revoke a specific device session |
#### Friend List
| Command | Description |
|---------|-------------|
| `/friend` | List friends with online/offline status |
| `/friend <address>` | Add a friend by fingerprint or alias |
| `/unfriend <address>` | 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:<fp>` | 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 <fingerprint>`
- 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 <fingerprint_or_alias>`.
- 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:<fp>:<device_id>`). 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 <address>` adds a friend (by fingerprint or alias).
- `/friend` lists all friends with their current online/offline status.
- `/unfriend <address>` 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": { "<fp>": "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 <device_id>` 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.