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:
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
58
warzone/crates/warzone-protocol/src/history.rs
Normal file
58
warzone/crates/warzone-protocol/src/history.rs
Normal file
@@ -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<u8> {
|
||||
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<Vec<u8>, 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());
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,4 @@ pub mod ratchet;
|
||||
pub mod message;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
pub mod history;
|
||||
|
||||
Reference in New Issue
Block a user