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:
@@ -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)
|
||||
|
||||
113
warzone/crates/warzone-protocol/src/friends.rs
Normal file
113
warzone/crates/warzone-protocol/src/friends.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,4 @@ pub mod store;
|
||||
pub mod history;
|
||||
pub mod sender_keys;
|
||||
pub mod ethereum;
|
||||
pub mod friends;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
390
warzone/crates/warzone-server/src/routes/bot.rs
Normal file
390
warzone/crates/warzone-server/src/routes/bot.rs
Normal 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,
|
||||
}
|
||||
}))
|
||||
}
|
||||
54
warzone/crates/warzone-server/src/routes/friends.rs
Normal file
54
warzone/crates/warzone-server/src/routes/friends.rs
Normal 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 })))
|
||||
}
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
102
warzone/crates/warzone-server/src/routes/resolve.rs
Normal file
102
warzone/crates/warzone-server/src/routes/resolve.rs
Normal 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" })))
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
### 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
159
warzone/docs/LLM_HELP.md
Normal 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/*
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Warzone Messenger (featherChat) — Usage Guide
|
||||
# featherChat Usage Guide
|
||||
|
||||
**Version:** 0.0.20
|
||||
**Version:** 0.0.21
|
||||
|
||||
---
|
||||
|
||||
@@ -11,284 +11,183 @@
|
||||
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 |
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-------------------------|----------|----------------------------|
|
||||
| `WARZONE_ADMIN_PASSWORD`| `admin` | Password for admin alias ops|
|
||||
|--------------------------|---------|------------------------------|
|
||||
| `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 |
|
||||
|---------|-------------|
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
|-------------------|----------------------------------------------|
|
||||
|---------|-------------|
|
||||
| `/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 |
|
||||
|
||||
```
|
||||
@@ -296,255 +195,206 @@ Group messages are prefixed with `#groupname` in the UI. The current target show
|
||||
/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 |
|
||||
|
||||
#### 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 |
|
||||
|-----------------|------------------------------|
|
||||
| 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 |
|
||||
|-----|--------|
|
||||
| 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 |
|
||||
| Enter | Send message / execute command|
|
||||
| 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) |
|
||||
|-----------|---------|
|
||||
| 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.
|
||||
|
||||
Reference in New Issue
Block a user