From 653c6c050ba3f770ee49cbdc538a7f412cea5cd2 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Fri, 27 Mar 2026 12:59:54 +0400 Subject: [PATCH] v0.0.12: Encrypted backup/restore + history module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol: - history.rs: derive_history_key (HKDF from seed, info="warzone-history") - encrypt_history / decrypt_history (ChaCha20-Poly1305, WZH1 magic) - 2 new tests (roundtrip + wrong seed), total 19/19 CLI: - `warzone backup [output.wzb]` — exports all sessions + pre-keys as encrypted blob (only your seed can decrypt) - `warzone restore ` — imports backup, merges (no overwrite) - Backup format: WZH1 magic + nonce + encrypted JSON Storage: - export_all() — dumps sessions + pre-keys as base64 JSON - import_all() — merges backup data (skip existing entries) Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.toml | 2 +- warzone/crates/warzone-client/src/main.rs | 30 ++++++++ warzone/crates/warzone-client/src/storage.rs | 70 +++++++++++++++++++ .../crates/warzone-protocol/src/history.rs | 58 +++++++++++++++ warzone/crates/warzone-protocol/src/lib.rs | 1 + 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 warzone/crates/warzone-protocol/src/history.rs diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index d8de1a5..22ccb03 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.11" +version = "0.0.12" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/main.rs b/warzone/crates/warzone-client/src/main.rs index fac2823..98bbf5c 100644 --- a/warzone/crates/warzone-client/src/main.rs +++ b/warzone/crates/warzone-client/src/main.rs @@ -55,6 +55,17 @@ enum Commands { #[arg(short, long, default_value = "http://localhost:7700")] server: String, }, + /// Export encrypted backup of local data (sessions, history) + Backup { + /// Output file path + #[arg(default_value = "warzone-backup.wzb")] + output: String, + }, + /// Restore from encrypted backup + Restore { + /// Backup file path + input: String, + }, } #[tokio::main] @@ -102,6 +113,25 @@ async fn main() -> anyhow::Result<()> { let db = storage::LocalDb::open()?; tui::run_tui(our_fp, peer, server, identity, poll_seed, db).await?; } + Commands::Backup { output } => { + let data_dir = keystore::data_dir(); + // Collect all sled data as JSON + let db = storage::LocalDb::open()?; + let backup_data = db.export_all()?; + let json = serde_json::to_vec(&backup_data)?; + let encrypted = warzone_protocol::history::encrypt_history(&seed.0, &json); + std::fs::write(&output, &encrypted)?; + println!("Backup saved to {} ({} bytes encrypted)", output, encrypted.len()); + } + Commands::Restore { input } => { + let encrypted = std::fs::read(&input)?; + let json = warzone_protocol::history::decrypt_history(&seed.0, &encrypted) + .map_err(|_| anyhow::anyhow!("Decryption failed — wrong seed?"))?; + let backup_data: serde_json::Value = serde_json::from_slice(&json)?; + let db = storage::LocalDb::open()?; + let count = db.import_all(&backup_data)?; + println!("Restored {} entries from {}", count, input); + } } Ok(()) diff --git a/warzone/crates/warzone-client/src/storage.rs b/warzone/crates/warzone-client/src/storage.rs index 614f22c..51871ec 100644 --- a/warzone/crates/warzone-client/src/storage.rs +++ b/warzone/crates/warzone-client/src/storage.rs @@ -1,6 +1,7 @@ //! Local sled database: sessions, pre-keys, message history. use anyhow::{Context, Result}; +use base64::Engine as _; use warzone_protocol::ratchet::RatchetState; use warzone_protocol::types::Fingerprint; use x25519_dalek::StaticSecret; @@ -108,4 +109,73 @@ impl LocalDb { None => Ok(None), } } + + /// Export all data as JSON (for encrypted backup). + pub fn export_all(&self) -> Result { + let mut sessions = serde_json::Map::new(); + for item in self.sessions.iter() { + if let Ok((k, v)) = item { + let key = String::from_utf8_lossy(&k).to_string(); + sessions.insert(key, serde_json::json!(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, &v + ))); + } + } + + let mut pre_keys = serde_json::Map::new(); + for item in self.pre_keys.iter() { + if let Ok((k, v)) = item { + let key = String::from_utf8_lossy(&k).to_string(); + pre_keys.insert(key, serde_json::json!(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, &v + ))); + } + } + + Ok(serde_json::json!({ + "version": 1, + "sessions": sessions, + "pre_keys": pre_keys, + })) + } + + /// Import data from JSON backup (merges, doesn't overwrite existing). + pub fn import_all(&self, data: &serde_json::Value) -> Result { + let mut count = 0; + + if let Some(sessions) = data.get("sessions").and_then(|v| v.as_object()) { + for (key, val) in sessions { + if let Some(b64) = val.as_str() { + if let Ok(bytes) = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, b64 + ) { + // Only import if not already present + if self.sessions.get(key.as_bytes())?.is_none() { + self.sessions.insert(key.as_bytes(), bytes)?; + count += 1; + } + } + } + } + } + + if let Some(pre_keys) = data.get("pre_keys").and_then(|v| v.as_object()) { + for (key, val) in pre_keys { + if let Some(b64) = val.as_str() { + if let Ok(bytes) = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, b64 + ) { + if self.pre_keys.get(key.as_bytes())?.is_none() { + self.pre_keys.insert(key.as_bytes(), bytes)?; + count += 1; + } + } + } + } + } + + self.sessions.flush()?; + self.pre_keys.flush()?; + Ok(count) + } } diff --git a/warzone/crates/warzone-protocol/src/history.rs b/warzone/crates/warzone-protocol/src/history.rs new file mode 100644 index 0000000..ecda1ea --- /dev/null +++ b/warzone/crates/warzone-protocol/src/history.rs @@ -0,0 +1,58 @@ +//! Encrypted message history: backup and restore. +//! +//! History key derived from seed via HKDF (info="warzone-history"). +//! Format: MAGIC(4) + nonce(12) + ciphertext (ChaCha20-Poly1305). + +use crate::crypto::{aead_decrypt, aead_encrypt, hkdf_derive}; +use crate::errors::ProtocolError; + +const HISTORY_MAGIC: &[u8; 4] = b"WZH1"; + +/// Derive history encryption key from seed. +pub fn derive_history_key(seed: &[u8; 32]) -> [u8; 32] { + let derived = hkdf_derive(seed, b"", b"warzone-history", 32); + let mut key = [0u8; 32]; + key.copy_from_slice(&derived); + key +} + +/// Encrypt a history blob (JSON messages serialized to bytes). +pub fn encrypt_history(seed: &[u8; 32], plaintext: &[u8]) -> Vec { + let key = derive_history_key(seed); + let encrypted = aead_encrypt(&key, plaintext, HISTORY_MAGIC); + let mut result = Vec::with_capacity(4 + encrypted.len()); + result.extend_from_slice(HISTORY_MAGIC); + result.extend_from_slice(&encrypted); + result +} + +/// Decrypt a history blob. +pub fn decrypt_history(seed: &[u8; 32], data: &[u8]) -> Result, ProtocolError> { + if data.len() < 4 || &data[..4] != HISTORY_MAGIC { + return Err(ProtocolError::DecryptionFailed); + } + let key = derive_history_key(seed); + aead_decrypt(&key, &data[4..], HISTORY_MAGIC) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let seed = [42u8; 32]; + let messages = b"[{\"from\":\"alice\",\"text\":\"hello\"}]"; + let encrypted = encrypt_history(&seed, messages); + let decrypted = decrypt_history(&seed, &encrypted).unwrap(); + assert_eq!(decrypted, messages); + } + + #[test] + fn wrong_seed_fails() { + let seed = [42u8; 32]; + let wrong = [99u8; 32]; + let encrypted = encrypt_history(&seed, b"secret"); + assert!(decrypt_history(&wrong, &encrypted).is_err()); + } +} diff --git a/warzone/crates/warzone-protocol/src/lib.rs b/warzone/crates/warzone-protocol/src/lib.rs index 7f5a3a3..1a90816 100644 --- a/warzone/crates/warzone-protocol/src/lib.rs +++ b/warzone/crates/warzone-protocol/src/lib.rs @@ -9,3 +9,4 @@ pub mod ratchet; pub mod message; pub mod session; pub mod store; +pub mod history;