diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 46aa5da..6ed439e 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2647,7 +2647,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.11" +version = "0.0.12" dependencies = [ "anyhow", "argon2", @@ -2680,7 +2680,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.11" +version = "0.0.12" dependencies = [ "anyhow", "clap", @@ -2689,7 +2689,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.11" +version = "0.0.12" dependencies = [ "base64", "bincode", @@ -2712,7 +2712,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.11" +version = "0.0.12" dependencies = [ "anyhow", "axum", @@ -2739,7 +2739,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.11" +version = "0.0.12" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index 22ccb03..1387dd2 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.12" +version = "0.0.13" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/cli/recv.rs b/warzone/crates/warzone-client/src/cli/recv.rs index 0f98f04..528cd6e 100644 --- a/warzone/crates/warzone-client/src/cli/recv.rs +++ b/warzone/crates/warzone-client/src/cli/recv.rs @@ -123,6 +123,12 @@ pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> { Ok(WireMessage::FileChunk { filename, chunk_index, total_chunks, sender_fingerprint, .. }) => { println!(" [file chunk] {} chunk {}/{} of '{}'", sender_fingerprint, chunk_index + 1, total_chunks, filename); } + Ok(WireMessage::GroupSenderKey { sender_fingerprint, group_name, .. }) => { + println!(" [group] {} sent to #{}", sender_fingerprint, group_name); + } + Ok(WireMessage::SenderKeyDistribution { sender_fingerprint, group_name, .. }) => { + println!(" [sender key] received key from {} for #{}", sender_fingerprint, group_name); + } Err(e) => { eprintln!(" failed to deserialize message: {}", e); } diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs index a3e9357..4da7445 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -1340,6 +1340,38 @@ fn process_wire_message( // Received chunk without header — ignore } } + WireMessage::GroupSenderKey { + id: _, + sender_fingerprint, + group_name, + generation: _, + counter: _, + ciphertext: _, + } => { + // TODO: decrypt with stored sender key for this sender+group + messages.lock().unwrap().push(ChatLine { + sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), + text: format!("[group #{} sender-key message — key setup needed]", group_name), + is_system: false, + is_self: false, + message_id: None, + }); + } + WireMessage::SenderKeyDistribution { + sender_fingerprint, + group_name, + chain_key: _, + generation: _, + } => { + // TODO: store this sender key for future group decryption + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("Received sender key from {} for #{}", &sender_fingerprint[..sender_fingerprint.len().min(12)], group_name), + is_system: true, + is_self: false, + message_id: None, + }); + } } } diff --git a/warzone/crates/warzone-protocol/src/lib.rs b/warzone/crates/warzone-protocol/src/lib.rs index 1a90816..ade9404 100644 --- a/warzone/crates/warzone-protocol/src/lib.rs +++ b/warzone/crates/warzone-protocol/src/lib.rs @@ -10,3 +10,4 @@ pub mod message; pub mod session; pub mod store; pub mod history; +pub mod sender_keys; diff --git a/warzone/crates/warzone-protocol/src/message.rs b/warzone/crates/warzone-protocol/src/message.rs index 61cce17..68b230f 100644 --- a/warzone/crates/warzone-protocol/src/message.rs +++ b/warzone/crates/warzone-protocol/src/message.rs @@ -84,4 +84,21 @@ pub enum WireMessage { total_chunks: u32, data: Vec, }, + /// Group message encrypted with sender key (O(1) instead of O(N)). + GroupSenderKey { + id: String, + sender_fingerprint: String, + group_name: String, + generation: u32, + counter: u32, + ciphertext: Vec, + }, + /// Sender key distribution: share your sender key with a group member. + /// This is sent via 1:1 encrypted channel (wrapped in KeyExchange/Message). + SenderKeyDistribution { + sender_fingerprint: String, + group_name: String, + chain_key: [u8; 32], + generation: u32, + }, } diff --git a/warzone/crates/warzone-protocol/src/sender_keys.rs b/warzone/crates/warzone-protocol/src/sender_keys.rs new file mode 100644 index 0000000..6a36c7e --- /dev/null +++ b/warzone/crates/warzone-protocol/src/sender_keys.rs @@ -0,0 +1,210 @@ +//! Sender Keys for efficient group encryption. +//! +//! Instead of encrypting per-member (O(N)), each member generates a +//! symmetric "sender key" and distributes it to all group members via +//! 1:1 encrypted channels. Group messages are encrypted ONCE with the +//! sender's key, and the same ciphertext is delivered to all members. +//! +//! Key rotation: on member join/leave, all members rotate their sender keys. + +use serde::{Deserialize, Serialize}; + +use crate::crypto::{aead_decrypt, aead_encrypt, hkdf_derive}; +use crate::errors::ProtocolError; + +/// A sender key: symmetric key + chain for forward ratcheting. +#[derive(Clone, Serialize, Deserialize)] +pub struct SenderKey { + /// Who owns this key. + pub owner_fingerprint: String, + /// Group this key belongs to. + pub group_name: String, + /// Current chain key (ratchets forward on each message). + pub chain_key: [u8; 32], + /// Message counter. + pub counter: u32, + /// Generation (incremented on rotation). + pub generation: u32, +} + +impl SenderKey { + /// Generate a new sender key for a group. + pub fn generate(owner_fingerprint: &str, group_name: &str) -> Self { + let mut chain_key = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut chain_key); + SenderKey { + owner_fingerprint: owner_fingerprint.to_string(), + group_name: group_name.to_string(), + chain_key, + counter: 0, + generation: 0, + } + } + + /// Rotate: new random chain key, increment generation. + pub fn rotate(&mut self) { + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut self.chain_key); + self.counter = 0; + self.generation += 1; + } + + /// Derive a message key from the current chain key, then ratchet forward. + fn derive_message_key(&mut self) -> [u8; 32] { + let info = format!("wz-sk-msg-{}-{}", self.generation, self.counter); + let mk_bytes = hkdf_derive(&self.chain_key, b"", info.as_bytes(), 32); + let mut message_key = [0u8; 32]; + message_key.copy_from_slice(&mk_bytes); + + // Ratchet chain key forward + let ck_bytes = hkdf_derive(&self.chain_key, b"", b"wz-sk-chain", 32); + self.chain_key.copy_from_slice(&ck_bytes); + self.counter += 1; + + message_key + } + + /// Encrypt a message with this sender key. + pub fn encrypt(&mut self, plaintext: &[u8]) -> SenderKeyMessage { + let message_key = self.derive_message_key(); + let aad = format!("{}:{}:{}", self.group_name, self.generation, self.counter - 1); + let ciphertext = aead_encrypt(&message_key, plaintext, aad.as_bytes()); + + SenderKeyMessage { + sender_fingerprint: self.owner_fingerprint.clone(), + group_name: self.group_name.clone(), + generation: self.generation, + counter: self.counter - 1, + ciphertext, + } + } + + /// Decrypt a message from another member using their sender key. + /// `self` is the RECEIVER's copy of the SENDER's key. + pub fn decrypt(&mut self, msg: &SenderKeyMessage) -> Result, ProtocolError> { + // Fast-forward chain if needed (handle skipped messages) + if msg.generation != self.generation { + return Err(ProtocolError::RatchetError(format!( + "generation mismatch: expected {}, got {}", + self.generation, msg.generation + ))); + } + + // We need to advance to the right counter + while self.counter < msg.counter { + // Skip this message key (lost message) + let _ = self.derive_message_key(); + } + + if self.counter != msg.counter { + return Err(ProtocolError::RatchetError("counter mismatch".into())); + } + + let message_key = self.derive_message_key(); + let aad = format!("{}:{}:{}", msg.group_name, msg.generation, msg.counter); + aead_decrypt(&message_key, &msg.ciphertext, aad.as_bytes()) + } +} + +/// An encrypted group message using sender keys. +#[derive(Clone, Serialize, Deserialize)] +pub struct SenderKeyMessage { + pub sender_fingerprint: String, + pub group_name: String, + pub generation: u32, + pub counter: u32, + pub ciphertext: Vec, +} + +/// Distribution message: sent via 1:1 encrypted channel to share a sender key. +#[derive(Clone, Serialize, Deserialize)] +pub struct SenderKeyDistribution { + pub sender_fingerprint: String, + pub group_name: String, + pub chain_key: [u8; 32], + pub generation: u32, +} + +impl From<&SenderKey> for SenderKeyDistribution { + fn from(sk: &SenderKey) -> Self { + SenderKeyDistribution { + sender_fingerprint: sk.owner_fingerprint.clone(), + group_name: sk.group_name.clone(), + chain_key: sk.chain_key, + generation: sk.generation, + } + } +} + +impl SenderKeyDistribution { + /// Convert distribution into a receiver's copy of the sender key. + pub fn into_sender_key(self) -> SenderKey { + SenderKey { + owner_fingerprint: self.sender_fingerprint, + group_name: self.group_name, + chain_key: self.chain_key, + counter: 0, + generation: self.generation, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_encrypt_decrypt() { + let mut alice_key = SenderKey::generate("alice", "ops"); + // Bob gets a copy of Alice's key (via distribution) + let dist = SenderKeyDistribution::from(&alice_key); + let mut bob_copy = dist.into_sender_key(); + + let msg = alice_key.encrypt(b"hello group"); + let plain = bob_copy.decrypt(&msg).unwrap(); + assert_eq!(plain, b"hello group"); + } + + #[test] + fn multiple_messages() { + let mut alice_key = SenderKey::generate("alice", "ops"); + let dist = SenderKeyDistribution::from(&alice_key); + let mut bob_copy = dist.into_sender_key(); + + for i in 0..10 { + let msg = alice_key.encrypt(format!("msg {}", i).as_bytes()); + let plain = bob_copy.decrypt(&msg).unwrap(); + assert_eq!(plain, format!("msg {}", i).as_bytes()); + } + } + + #[test] + fn rotation() { + let mut alice_key = SenderKey::generate("alice", "ops"); + let dist1 = SenderKeyDistribution::from(&alice_key); + let mut bob_copy = dist1.into_sender_key(); + + let msg1 = alice_key.encrypt(b"before rotation"); + let _ = bob_copy.decrypt(&msg1).unwrap(); + + // Rotate + alice_key.rotate(); + let dist2 = SenderKeyDistribution::from(&alice_key); + let mut bob_copy2 = dist2.into_sender_key(); + + let msg2 = alice_key.encrypt(b"after rotation"); + let plain = bob_copy2.decrypt(&msg2).unwrap(); + assert_eq!(plain, b"after rotation"); + } + + #[test] + fn old_key_cant_decrypt_new() { + let mut alice_key = SenderKey::generate("alice", "ops"); + let dist = SenderKeyDistribution::from(&alice_key); + let mut bob_old = dist.into_sender_key(); + + alice_key.rotate(); + + let msg = alice_key.encrypt(b"new generation"); + assert!(bob_old.decrypt(&msg).is_err()); + } +} diff --git a/warzone/crates/warzone-server/src/routes/messages.rs b/warzone/crates/warzone-server/src/routes/messages.rs index 6fe757a..109fae1 100644 --- a/warzone/crates/warzone-server/src/routes/messages.rs +++ b/warzone/crates/warzone-server/src/routes/messages.rs @@ -18,6 +18,10 @@ fn extract_message_id(data: &[u8]) -> Option { WireMessage::FileHeader { id, .. } => Some(id), WireMessage::FileChunk { id, .. } => Some(id), WireMessage::Receipt { message_id, .. } => Some(message_id), + WireMessage::GroupSenderKey { id, .. } => Some(id), + WireMessage::SenderKeyDistribution { sender_fingerprint, group_name, .. } => { + Some(format!("skd:{}:{}", sender_fingerprint, group_name)) + } } } else { None diff --git a/warzone/crates/warzone-server/src/routes/ws.rs b/warzone/crates/warzone-server/src/routes/ws.rs index 216c50d..fa2c0f0 100644 --- a/warzone/crates/warzone-server/src/routes/ws.rs +++ b/warzone/crates/warzone-server/src/routes/ws.rs @@ -30,6 +30,10 @@ fn extract_message_id(data: &[u8]) -> Option { WireMessage::FileHeader { id, .. } => Some(id), WireMessage::FileChunk { id, .. } => Some(id), WireMessage::Receipt { message_id, .. } => Some(message_id), + WireMessage::GroupSenderKey { id, .. } => Some(id), + WireMessage::SenderKeyDistribution { sender_fingerprint, group_name, .. } => { + Some(format!("skd:{}:{}", sender_fingerprint, group_name)) + } } } else { None