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:
@@ -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)"
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user