v0.0.38: FC-P4 complete — session versioning, wire envelope, auto-backup
FC-P4-T1: Session State Versioning - RatchetState serialize/deserialize with [MAGIC:0xFC][VERSION:1][bincode] - Legacy (raw bincode) still loads — backward compatible - Client + WASM both use versioned format - 2 new tests: roundtrip + legacy compat FC-P4-T2: WireMessage Versioning Envelope - Format: [WZ magic][version:u8][length:u32 BE][bincode payload] - Server + client + WASM accept both envelope and legacy on receive - Client still sends raw bincode (server handles both) - Future version → "update required" error instead of crash - 3 new tests: roundtrip, legacy compat, future version rejection FC-P4-T3: Periodic Auto-Backup - Every 5 minutes, encrypts sessions+contacts+sender_keys to ~/.warzone/backups/ - HKDF-derived key from seed, ChaCha20-Poly1305 AEAD - Atomic writes (temp file + rename), rotates to keep last 3 - /backup command for manual trigger 127 tests passing (was 122) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -2956,7 +2956,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-client"
|
name = "warzone-client"
|
||||||
version = "0.0.37"
|
version = "0.0.38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-mule"
|
name = "warzone-mule"
|
||||||
version = "0.0.37"
|
version = "0.0.38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.37"
|
version = "0.0.38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-server"
|
name = "warzone-server"
|
||||||
version = "0.0.37"
|
version = "0.0.38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3053,7 +3053,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-wasm"
|
name = "warzone-wasm"
|
||||||
version = "0.0.37"
|
version = "0.0.38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.37"
|
version = "0.0.38"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ impl LocalDb {
|
|||||||
/// Save a ratchet session for a peer.
|
/// Save a ratchet session for a peer.
|
||||||
pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> {
|
pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> {
|
||||||
let key = peer.to_hex();
|
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.insert(key.as_bytes(), data)?;
|
||||||
self.sessions.flush()?;
|
self.sessions.flush()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -73,8 +74,8 @@ impl LocalDb {
|
|||||||
let key = peer.to_hex();
|
let key = peer.to_hex();
|
||||||
match self.sessions.get(key.as_bytes())? {
|
match self.sessions.get(key.as_bytes())? {
|
||||||
Some(data) => {
|
Some(data) => {
|
||||||
let state = bincode::deserialize(&data)
|
let state = RatchetState::deserialize_versioned(&data)
|
||||||
.context("failed to deserialize session")?;
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
Ok(Some(state))
|
Ok(Some(state))
|
||||||
}
|
}
|
||||||
None => Ok(None),
|
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<std::path::PathBuf> {
|
||||||
|
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::<serde_json::Value>(&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).
|
/// Import data from JSON backup (merges, doesn't overwrite existing).
|
||||||
pub fn import_all(&self, data: &serde_json::Value) -> Result<usize> {
|
pub fn import_all(&self, data: &serde_json::Value) -> Result<usize> {
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ impl App {
|
|||||||
" /info Show your fingerprint",
|
" /info Show your fingerprint",
|
||||||
" /eth Show Ethereum address",
|
" /eth Show Ethereum address",
|
||||||
" /seed Show recovery mnemonic (24 words)",
|
" /seed Show recovery mnemonic (24 words)",
|
||||||
|
" /backup Create encrypted backup now",
|
||||||
" /peer <fp>, /p Set DM peer by fingerprint",
|
" /peer <fp>, /p Set DM peer by fingerprint",
|
||||||
" /peer @alias Set DM peer by alias",
|
" /peer @alias Set DM peer by alias",
|
||||||
" /reply, /r Reply to last DM sender",
|
" /reply, /r Reply to last DM sender",
|
||||||
@@ -189,6 +190,19 @@ impl App {
|
|||||||
}
|
}
|
||||||
return;
|
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" {
|
if text == "/friend" || text == "/friends" {
|
||||||
// Fetch encrypted friend list from server, decrypt locally
|
// Fetch encrypted friend list from server, decrypt locally
|
||||||
let url = format!("{}/v1/friends", client.base_url);
|
let url = format!("{}/v1/friends", client.base_url);
|
||||||
|
|||||||
@@ -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;
|
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)
|
// Auto-join #ops if no peer set (create if needed)
|
||||||
if app.peer_fp.is_none() {
|
if app.peer_fp.is_none() {
|
||||||
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ pub fn process_incoming(
|
|||||||
eth_cache: &EthCache,
|
eth_cache: &EthCache,
|
||||||
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||||
) {
|
) {
|
||||||
match bincode::deserialize::<WireMessage>(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),
|
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer, eth_cache),
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.37"
|
version = "0.0.38"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub enum ReceiptType {
|
|||||||
|
|
||||||
/// Wire message format for transport between clients.
|
/// Wire message format for transport between clients.
|
||||||
/// Used by both CLI and WASM — MUST be identical for interop.
|
/// Used by both CLI and WASM — MUST be identical for interop.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub enum WireMessage {
|
pub enum WireMessage {
|
||||||
/// First message to a peer: X3DH key exchange + first ratchet message.
|
/// First message to a peer: X3DH key exchange + first ratchet message.
|
||||||
KeyExchange {
|
KeyExchange {
|
||||||
@@ -132,3 +132,104 @@ pub enum CallSignalType {
|
|||||||
/// Peer is busy.
|
/// Peer is busy.
|
||||||
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<Vec<u8>, 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<WireMessage, String> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,15 +11,20 @@ use crate::errors::ProtocolError;
|
|||||||
|
|
||||||
const MAX_SKIP: u32 = 1000;
|
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.
|
/// A message produced by the ratchet.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct RatchetMessage {
|
pub struct RatchetMessage {
|
||||||
pub header: RatchetHeader,
|
pub header: RatchetHeader,
|
||||||
pub ciphertext: Vec<u8>,
|
pub ciphertext: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header included with each ratchet message.
|
/// Header included with each ratchet message.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct RatchetHeader {
|
pub struct RatchetHeader {
|
||||||
/// Current DH ratchet public key.
|
/// Current DH ratchet public key.
|
||||||
pub dh_public: [u8; 32],
|
pub dh_public: [u8; 32],
|
||||||
@@ -208,6 +213,37 @@ impl RatchetState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize with version prefix: `[MAGIC][VERSION][bincode data]`.
|
||||||
|
///
|
||||||
|
/// Use [`deserialize_versioned`](Self::deserialize_versioned) to restore.
|
||||||
|
pub fn serialize_versioned(&self) -> Result<Vec<u8>, 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<Self, String> {
|
||||||
|
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> {
|
fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> {
|
||||||
let their_pub = self
|
let their_pub = self
|
||||||
.dh_remote
|
.dh_remote
|
||||||
@@ -312,6 +348,35 @@ mod tests {
|
|||||||
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
|
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]
|
#[test]
|
||||||
fn many_messages() {
|
fn many_messages() {
|
||||||
let (mut alice, mut bob) = make_pair();
|
let (mut alice, mut bob) = make_pair();
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ use warzone_protocol::message::WireMessage;
|
|||||||
use crate::errors::AppResult;
|
use crate::errors::AppResult;
|
||||||
use crate::state::AppState;
|
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<String> {
|
fn extract_message_id(data: &[u8]) -> Option<String> {
|
||||||
if let Ok(wire) = bincode::deserialize::<WireMessage>(data) {
|
if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) {
|
||||||
match wire {
|
match wire {
|
||||||
WireMessage::KeyExchange { id, .. } => Some(id),
|
WireMessage::KeyExchange { id, .. } => Some(id),
|
||||||
WireMessage::Message { id, .. } => Some(id),
|
WireMessage::Message { id, .. } => Some(id),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
|
|||||||
|
|
||||||
async fn service_worker() -> impl IntoResponse {
|
async fn service_worker() -> impl IntoResponse {
|
||||||
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
([(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'];
|
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||||
|
|
||||||
self.addEventListener('install', e => {
|
self.addEventListener('install', e => {
|
||||||
@@ -287,7 +287,7 @@ let pollTimer = null;
|
|||||||
let ws = null; // WebSocket connection
|
let ws = null; // WebSocket connection
|
||||||
let wasmReady = false;
|
let wasmReady = false;
|
||||||
|
|
||||||
const VERSION = '0.0.37';
|
const VERSION = '0.0.38';
|
||||||
let DEBUG = true; // toggle with /debug command
|
let DEBUG = true; // toggle with /debug command
|
||||||
|
|
||||||
// ── Receipt tracking ──
|
// ── Receipt tracking ──
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ use warzone_protocol::message::WireMessage;
|
|||||||
|
|
||||||
use crate::state::AppState;
|
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<String> {
|
fn extract_message_id(data: &[u8]) -> Option<String> {
|
||||||
if let Ok(wire) = bincode::deserialize::<WireMessage>(data) {
|
if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) {
|
||||||
match wire {
|
match wire {
|
||||||
WireMessage::KeyExchange { id, .. } => Some(id),
|
WireMessage::KeyExchange { id, .. } => Some(id),
|
||||||
WireMessage::Message { 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
|
// Call signal side effects
|
||||||
if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = bincode::deserialize::<WireMessage>(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;
|
use warzone_protocol::message::CallSignalType;
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
match signal_type {
|
match signal_type {
|
||||||
|
|||||||
@@ -212,15 +212,16 @@ impl WasmSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) -> Result<String, JsValue> {
|
pub fn save(&self) -> Result<String, JsValue> {
|
||||||
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))
|
Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore(data: &str) -> Result<WasmSession, JsValue> {
|
pub fn restore(data: &str) -> Result<WasmSession, JsValue> {
|
||||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data)
|
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data)
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
let ratchet: RatchetState = bincode::deserialize(&bytes)
|
let ratchet = RatchetState::deserialize_versioned(&bytes)
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
.map_err(|e| JsValue::from_str(&e))?;
|
||||||
Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None })
|
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 seed = Seed::from_bytes(sb);
|
||||||
let id = seed.derive_identity();
|
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)))?;
|
.map_err(|e| JsValue::from_str(&format!("deserialize wire: {}", e)))?;
|
||||||
|
|
||||||
match wire {
|
match wire {
|
||||||
@@ -403,7 +404,7 @@ pub fn decrypt_wire_message(
|
|||||||
|
|
||||||
let session_b64 = base64::Engine::encode(
|
let session_b64 = base64::Engine::encode(
|
||||||
&base64::engine::general_purpose::STANDARD,
|
&base64::engine::general_purpose::STANDARD,
|
||||||
&bincode::serialize(&ratchet).unwrap_or_default(),
|
&ratchet.serialize_versioned().unwrap_or_default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@@ -424,15 +425,15 @@ pub fn decrypt_wire_message(
|
|||||||
let session_bytes = base64::Engine::decode(
|
let session_bytes = base64::Engine::decode(
|
||||||
&base64::engine::general_purpose::STANDARD, &session_data,
|
&base64::engine::general_purpose::STANDARD, &session_data,
|
||||||
).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
let mut ratchet: RatchetState = bincode::deserialize(&session_bytes)
|
let mut ratchet = RatchetState::deserialize_versioned(&session_bytes)
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
.map_err(|e| JsValue::from_str(&e))?;
|
||||||
|
|
||||||
let plain = ratchet.decrypt(&ratchet_message)
|
let plain = ratchet.decrypt(&ratchet_message)
|
||||||
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
|
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
|
||||||
|
|
||||||
let session_b64 = base64::Engine::encode(
|
let session_b64 = base64::Engine::encode(
|
||||||
&base64::engine::general_purpose::STANDARD,
|
&base64::engine::general_purpose::STANDARD,
|
||||||
&bincode::serialize(&ratchet).unwrap_or_default(),
|
&ratchet.serialize_versioned().unwrap_or_default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
|
|||||||
Reference in New Issue
Block a user