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:
Siavash Sameni
2026-03-29 17:03:02 +04:00
parent a368ab24d2
commit 5764719375
13 changed files with 309 additions and 29 deletions

View File

@@ -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)"

View File

@@ -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<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"));
}
}

View File

@@ -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<u8>,
}
/// 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<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> {
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();