diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 6d7ffc3..cb268f1 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.37" +version = "0.0.38" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.37" +version = "0.0.38" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.37" +version = "0.0.38" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.37" +version = "0.0.38" dependencies = [ "anyhow", "axum", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.37" +version = "0.0.38" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 3030685..8f897d9 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.37" +version = "0.0.38" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/storage.rs b/warzone/crates/warzone-client/src/storage.rs index 4691006..b1dccbd 100644 --- a/warzone/crates/warzone-client/src/storage.rs +++ b/warzone/crates/warzone-client/src/storage.rs @@ -54,7 +54,8 @@ impl LocalDb { /// Save a ratchet session for a peer. pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> { let key = peer.to_hex(); - let data = bincode::serialize(state).context("failed to serialize session")?; + let data = state.serialize_versioned() + .map_err(|e| anyhow::anyhow!("{}", e))?; self.sessions.insert(key.as_bytes(), data)?; self.sessions.flush()?; Ok(()) @@ -73,8 +74,8 @@ impl LocalDb { let key = peer.to_hex(); match self.sessions.get(key.as_bytes())? { Some(data) => { - let state = bincode::deserialize(&data) - .context("failed to deserialize session")?; + let state = RatchetState::deserialize_versioned(&data) + .map_err(|e| anyhow::anyhow!("{}", e))?; Ok(Some(state)) } None => Ok(None), @@ -272,6 +273,87 @@ impl LocalDb { })) } + /// Create an encrypted backup of all session data. + /// Returns the backup file path. + pub fn create_backup(&self, seed: &[u8; 32]) -> Result { + use std::io::Write; + + let backup_dir = crate::keystore::data_dir().join("backups"); + std::fs::create_dir_all(&backup_dir)?; + + // Collect all data + let mut data = serde_json::Map::new(); + + // Sessions + let mut sessions = serde_json::Map::new(); + for item in self.sessions.iter() { + if let Ok((key, value)) = item { + let k = String::from_utf8_lossy(&key).to_string(); + sessions.insert(k, serde_json::Value::String(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, &value + ))); + } + } + data.insert("sessions".into(), serde_json::Value::Object(sessions)); + + // Contacts + let mut contacts = serde_json::Map::new(); + for item in self.contacts.iter() { + if let Ok((key, value)) = item { + let k = String::from_utf8_lossy(&key).to_string(); + if let Ok(v) = serde_json::from_slice::(&value) { + contacts.insert(k, v); + } + } + } + data.insert("contacts".into(), serde_json::Value::Object(contacts)); + + // Sender keys + let mut sender_keys = serde_json::Map::new(); + for item in self.sender_keys.iter() { + if let Ok((key, value)) = item { + let k = String::from_utf8_lossy(&key).to_string(); + sender_keys.insert(k, serde_json::Value::String(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, &value + ))); + } + } + data.insert("sender_keys".into(), serde_json::Value::Object(sender_keys)); + + // Serialize and encrypt + let plaintext = serde_json::to_vec(&serde_json::Value::Object(data))?; + let key_bytes = warzone_protocol::crypto::hkdf_derive(seed, b"", b"warzone-backup", 32); + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + let encrypted = warzone_protocol::crypto::aead_encrypt(&key, &plaintext, b"warzone-backup-aad"); + + // Write to temp file then rename (atomic) + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string(); + let filename = format!("backup_{}.wzbk", timestamp); + let path = backup_dir.join(&filename); + let tmp_path = backup_dir.join(format!(".{}.tmp", filename)); + + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(&encrypted)?; + file.sync_all()?; + std::fs::rename(&tmp_path, &path)?; + + // Rotate: keep last 3 backups + let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)? + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".wzbk")) + .collect(); + backups.sort_by_key(|e| e.file_name()); + while backups.len() > 3 { + if let Some(old) = backups.first() { + let _ = std::fs::remove_file(old.path()); + backups.remove(0); + } + } + + Ok(path) + } + /// Import data from JSON backup (merges, doesn't overwrite existing). pub fn import_all(&self, data: &serde_json::Value) -> Result { let mut count = 0; diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index f67458f..f4ef605 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -47,6 +47,7 @@ impl App { " /info Show your fingerprint", " /eth Show Ethereum address", " /seed Show recovery mnemonic (24 words)", + " /backup Create encrypted backup now", " /peer , /p Set DM peer by fingerprint", " /peer @alias Set DM peer by alias", " /reply, /r Reply to last DM sender", @@ -189,6 +190,19 @@ impl App { } return; } + if text == "/backup" { + if let Ok(seed) = crate::keystore::load_seed_raw() { + match db.create_backup(&seed) { + Ok(path) => { + self.add_message(ChatLine { sender: "system".into(), text: format!("Backup saved: {}", path.display()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + } + Err(e) => { + self.add_message(ChatLine { sender: "system".into(), text: format!("Backup failed: {}", e), 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); diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index 2b92b68..00677f3 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -48,6 +48,23 @@ pub async fn run_tui( network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await; }); + // Spawn periodic backup task (every 5 minutes) + { + let backup_db = db.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); + loop { + interval.tick().await; + if let Ok(seed) = crate::keystore::load_seed_raw() { + match backup_db.create_backup(&seed) { + Ok(path) => tracing::debug!("Auto-backup created: {}", path.display()), + Err(e) => tracing::warn!("Auto-backup failed: {}", e), + } + } + } + }); + } + // Auto-join #ops if no peer set (create if needed) if app.peer_fp.is_none() { let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase(); diff --git a/warzone/crates/warzone-client/src/tui/network.rs b/warzone/crates/warzone-client/src/tui/network.rs index d64c177..3047cc7 100644 --- a/warzone/crates/warzone-client/src/tui/network.rs +++ b/warzone/crates/warzone-client/src/tui/network.rs @@ -93,7 +93,7 @@ pub fn process_incoming( eth_cache: &EthCache, last_dm_peer: &Arc>>, ) { - match bincode::deserialize::(raw) { + match warzone_protocol::message::deserialize_envelope(raw) { Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer, eth_cache), Err(_) => {} } diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index 161f497..0b4b7bc 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.37" +version = "0.0.38" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-protocol/src/message.rs b/warzone/crates/warzone-protocol/src/message.rs index 7dddc6c..ee0e829 100644 --- a/warzone/crates/warzone-protocol/src/message.rs +++ b/warzone/crates/warzone-protocol/src/message.rs @@ -43,7 +43,7 @@ pub enum ReceiptType { /// Wire message format for transport between clients. /// Used by both CLI and WASM — MUST be identical for interop. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub enum WireMessage { /// First message to a peer: X3DH key exchange + first ratchet message. KeyExchange { @@ -132,3 +132,104 @@ pub enum CallSignalType { /// Peer is busy. Busy, } + +/// Current wire protocol version. +pub const WIRE_VERSION: u8 = 1; +/// Magic bytes to identify versioned envelope: "WZ" +pub const WIRE_MAGIC: [u8; 2] = [0x57, 0x5A]; + +/// Serialize a WireMessage with version envelope. +/// Format: [0x57][0x5A][version: u8][length: u32 BE][bincode payload] +pub fn serialize_envelope(msg: &WireMessage) -> Result, String> { + let payload = + bincode::serialize(msg).map_err(|e| format!("serialize: {}", e))?; + let len = payload.len() as u32; + let mut out = Vec::with_capacity(7 + payload.len()); + out.extend_from_slice(&WIRE_MAGIC); + out.push(WIRE_VERSION); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&payload); + Ok(out) +} + +/// Deserialize a WireMessage, handling both envelope and legacy formats. +/// - Envelope: [0x57][0x5A][version][length][payload] +/// - Legacy: raw bincode (no envelope) +pub fn deserialize_envelope(data: &[u8]) -> Result { + if data.len() >= 7 && data[0] == WIRE_MAGIC[0] && data[1] == WIRE_MAGIC[1] { + let version = data[2]; + let len = + u32::from_be_bytes([data[3], data[4], data[5], data[6]]) as usize; + if version > WIRE_VERSION { + return Err(format!( + "unsupported wire version {} (max {}). Please update your client.", + version, WIRE_VERSION + )); + } + if data.len() < 7 + len { + return Err("truncated envelope".to_string()); + } + bincode::deserialize(&data[7..7 + len]) + .map_err(|e| format!("v{} deserialize: {}", version, e)) + } else { + // Legacy: raw bincode + bincode::deserialize(data) + .map_err(|e| format!("legacy deserialize: {}", e)) + } +} + +#[cfg(test)] +mod envelope_tests { + use super::*; + + #[test] + fn envelope_roundtrip() { + let msg = WireMessage::Receipt { + sender_fingerprint: "abc123".to_string(), + message_id: "msg-001".to_string(), + receipt_type: ReceiptType::Delivered, + }; + let envelope = serialize_envelope(&msg).unwrap(); + assert_eq!(&envelope[..2], &WIRE_MAGIC); + assert_eq!(envelope[2], WIRE_VERSION); + + let decoded = deserialize_envelope(&envelope).unwrap(); + match decoded { + WireMessage::Receipt { message_id, .. } => { + assert_eq!(message_id, "msg-001") + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn legacy_still_works() { + let msg = WireMessage::Receipt { + sender_fingerprint: "abc123".to_string(), + message_id: "msg-002".to_string(), + receipt_type: ReceiptType::Read, + }; + let raw = bincode::serialize(&msg).unwrap(); + let decoded = deserialize_envelope(&raw).unwrap(); + match decoded { + WireMessage::Receipt { message_id, .. } => { + assert_eq!(message_id, "msg-002") + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn future_version_rejected() { + let mut envelope = serialize_envelope(&WireMessage::Receipt { + sender_fingerprint: "x".into(), + message_id: "y".into(), + receipt_type: ReceiptType::Delivered, + }) + .unwrap(); + envelope[2] = 99; // fake future version + let result = deserialize_envelope(&envelope); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("unsupported wire version")); + } +} diff --git a/warzone/crates/warzone-protocol/src/ratchet.rs b/warzone/crates/warzone-protocol/src/ratchet.rs index a5fa213..b64daf0 100644 --- a/warzone/crates/warzone-protocol/src/ratchet.rs +++ b/warzone/crates/warzone-protocol/src/ratchet.rs @@ -11,15 +11,20 @@ use crate::errors::ProtocolError; const MAX_SKIP: u32 = 1000; +/// Current serialization version for [`RatchetState`]. +const RATCHET_VERSION: u8 = 1; +/// Magic byte to distinguish versioned from unversioned (legacy) data. +const RATCHET_MAGIC: u8 = 0xFC; + /// A message produced by the ratchet. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct RatchetMessage { pub header: RatchetHeader, pub ciphertext: Vec, } /// Header included with each ratchet message. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct RatchetHeader { /// Current DH ratchet public key. pub dh_public: [u8; 32], @@ -208,6 +213,37 @@ impl RatchetState { Ok(()) } + /// Serialize with version prefix: `[MAGIC][VERSION][bincode data]`. + /// + /// Use [`deserialize_versioned`](Self::deserialize_versioned) to restore. + pub fn serialize_versioned(&self) -> Result, String> { + let data = bincode::serialize(self) + .map_err(|e| format!("serialize: {}", e))?; + let mut out = Vec::with_capacity(2 + data.len()); + out.push(RATCHET_MAGIC); + out.push(RATCHET_VERSION); + out.extend_from_slice(&data); + Ok(out) + } + + /// Deserialize with version awareness. Handles: + /// - Versioned format: `[0xFC][version][bincode]` + /// - Legacy format: raw bincode (no prefix) + pub fn deserialize_versioned(data: &[u8]) -> Result { + if data.len() >= 2 && data[0] == RATCHET_MAGIC { + let version = data[1]; + match version { + 1 => bincode::deserialize(&data[2..]) + .map_err(|e| format!("v1 deserialize: {}", e)), + _ => Err(format!("unknown ratchet version: {}", version)), + } + } else { + // Legacy: try raw bincode (pre-versioning data) + bincode::deserialize(data) + .map_err(|e| format!("legacy deserialize: {}", e)) + } + } + fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> { let their_pub = self .dh_remote @@ -312,6 +348,35 @@ mod tests { assert_eq!(bob.decrypt(&m2).unwrap(), b"two"); } + #[test] + fn versioned_serialize_roundtrip() { + let (mut alice, mut bob) = make_pair(); + let msg = alice.encrypt(b"test versioning").unwrap(); + + // Save alice with versioned format + let serialized = alice.serialize_versioned().unwrap(); + assert_eq!(serialized[0], 0xFC); // magic byte + assert_eq!(serialized[1], 1); // version 1 + + // Restore and use + let mut restored = RatchetState::deserialize_versioned(&serialized).unwrap(); + let msg2 = restored.encrypt(b"after restore").unwrap(); + let plain = bob.decrypt(&msg).unwrap(); + assert_eq!(plain, b"test versioning"); + let plain2 = bob.decrypt(&msg2).unwrap(); + assert_eq!(plain2, b"after restore"); + } + + #[test] + fn legacy_deserialize_works() { + let (alice, _) = make_pair(); + // Serialize with raw bincode (legacy format) + let legacy = bincode::serialize(&alice).unwrap(); + // Should still deserialize with versioned reader + let restored = RatchetState::deserialize_versioned(&legacy).unwrap(); + assert_eq!(bincode::serialize(&restored).unwrap(), legacy); + } + #[test] fn many_messages() { let (mut alice, mut bob) = make_pair(); diff --git a/warzone/crates/warzone-server/src/routes/messages.rs b/warzone/crates/warzone-server/src/routes/messages.rs index f1c90ec..21f82b8 100644 --- a/warzone/crates/warzone-server/src/routes/messages.rs +++ b/warzone/crates/warzone-server/src/routes/messages.rs @@ -9,9 +9,9 @@ use warzone_protocol::message::WireMessage; use crate::errors::AppResult; use crate::state::AppState; -/// Try to extract the message ID from raw bincode-serialized WireMessage bytes. +/// Try to extract the message ID from raw WireMessage bytes (envelope or legacy). fn extract_message_id(data: &[u8]) -> Option { - if let Ok(wire) = bincode::deserialize::(data) { + if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) { match wire { WireMessage::KeyExchange { id, .. } => Some(id), WireMessage::Message { id, .. } => Some(id), diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 88b5378..0e9f664 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v19'; +const CACHE = 'wz-v20'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -287,7 +287,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.37'; +const VERSION = '0.0.38'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ── diff --git a/warzone/crates/warzone-server/src/routes/ws.rs b/warzone/crates/warzone-server/src/routes/ws.rs index fc73219..f440fde 100644 --- a/warzone/crates/warzone-server/src/routes/ws.rs +++ b/warzone/crates/warzone-server/src/routes/ws.rs @@ -21,9 +21,9 @@ use warzone_protocol::message::WireMessage; use crate::state::AppState; -/// Try to extract the message ID from raw bincode-serialized WireMessage bytes. +/// Try to extract the message ID from raw WireMessage bytes (envelope or legacy). fn extract_message_id(data: &[u8]) -> Option { - if let Ok(wire) = bincode::deserialize::(data) { + if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) { match wire { WireMessage::KeyExchange { id, .. } => Some(id), WireMessage::Message { id, .. } => Some(id), @@ -147,7 +147,7 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String) } // Call signal side effects - if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = bincode::deserialize::(message) { + if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = warzone_protocol::message::deserialize_envelope(message) { use warzone_protocol::message::CallSignalType; let now = chrono::Utc::now().timestamp(); match signal_type { diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs index 396c07a..3895be3 100644 --- a/warzone/crates/warzone-wasm/src/lib.rs +++ b/warzone/crates/warzone-wasm/src/lib.rs @@ -212,15 +212,16 @@ impl WasmSession { } pub fn save(&self) -> Result { - let bytes = bincode::serialize(&self.ratchet).map_err(|e| JsValue::from_str(&e.to_string()))?; + let bytes = self.ratchet.serialize_versioned() + .map_err(|e| JsValue::from_str(&e))?; Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes)) } pub fn restore(data: &str) -> Result { let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data) .map_err(|e| JsValue::from_str(&e.to_string()))?; - let ratchet: RatchetState = bincode::deserialize(&bytes) - .map_err(|e| JsValue::from_str(&e.to_string()))?; + let ratchet = RatchetState::deserialize_versioned(&bytes) + .map_err(|e| JsValue::from_str(&e))?; Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None }) } } @@ -372,7 +373,7 @@ pub fn decrypt_wire_message( let seed = Seed::from_bytes(sb); let id = seed.derive_identity(); - let wire: WireMessage = bincode::deserialize(message_bytes) + let wire: WireMessage = warzone_protocol::message::deserialize_envelope(message_bytes) .map_err(|e| JsValue::from_str(&format!("deserialize wire: {}", e)))?; match wire { @@ -403,7 +404,7 @@ pub fn decrypt_wire_message( let session_b64 = base64::Engine::encode( &base64::engine::general_purpose::STANDARD, - &bincode::serialize(&ratchet).unwrap_or_default(), + &ratchet.serialize_versioned().unwrap_or_default(), ); Ok(serde_json::json!({ @@ -424,15 +425,15 @@ pub fn decrypt_wire_message( let session_bytes = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, &session_data, ).map_err(|e| JsValue::from_str(&e.to_string()))?; - let mut ratchet: RatchetState = bincode::deserialize(&session_bytes) - .map_err(|e| JsValue::from_str(&e.to_string()))?; + let mut ratchet = RatchetState::deserialize_versioned(&session_bytes) + .map_err(|e| JsValue::from_str(&e))?; let plain = ratchet.decrypt(&ratchet_message) .map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?; let session_b64 = base64::Engine::encode( &base64::engine::general_purpose::STANDARD, - &bincode::serialize(&ratchet).unwrap_or_default(), + &ratchet.serialize_versioned().unwrap_or_default(), ); Ok(serde_json::json!({