v0.0.12: Encrypted backup/restore + history module
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 <input.wzb>` — 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(())
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
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<usize> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user