v0.0.13: Sender Keys for efficient group encryption
Protocol (sender_keys.rs): - SenderKey: symmetric key with chain ratchet (forward secrecy per chain) - generate(), rotate(), encrypt(), decrypt() - SenderKeyDistribution: share key via 1:1 encrypted channel - SenderKeyMessage: encrypted group message (O(1) instead of O(N)) - Chain key ratchets forward on each message (HKDF) - Generation counter for key rotation tracking - 4 tests: basic, multi-message, rotation, old-key rejection WireMessage: - GroupSenderKey variant: encrypted group message - SenderKeyDistribution variant: key sharing Server: dedup handles new variants. CLI TUI + recv: stub handlers for new message types. 23/23 protocol tests pass. 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
@@ -2647,7 +2647,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-client"
|
name = "warzone-client"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -2680,7 +2680,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-mule"
|
name = "warzone-mule"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2689,7 +2689,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -2712,7 +2712,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-server"
|
name = "warzone-server"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2739,7 +2739,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-wasm"
|
name = "warzone-wasm"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.12"
|
version = "0.0.13"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ pub async fn run(server_url: &str, identity: &IdentityKeyPair) -> Result<()> {
|
|||||||
Ok(WireMessage::FileChunk { filename, chunk_index, total_chunks, sender_fingerprint, .. }) => {
|
Ok(WireMessage::FileChunk { filename, chunk_index, total_chunks, sender_fingerprint, .. }) => {
|
||||||
println!(" [file chunk] {} chunk {}/{} of '{}'", sender_fingerprint, chunk_index + 1, total_chunks, filename);
|
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) => {
|
Err(e) => {
|
||||||
eprintln!(" failed to deserialize message: {}", e);
|
eprintln!(" failed to deserialize message: {}", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1340,6 +1340,38 @@ fn process_wire_message(
|
|||||||
// Received chunk without header — ignore
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ pub mod message;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod sender_keys;
|
||||||
|
|||||||
@@ -84,4 +84,21 @@ pub enum WireMessage {
|
|||||||
total_chunks: u32,
|
total_chunks: u32,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// 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<u8>,
|
||||||
|
},
|
||||||
|
/// 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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
210
warzone/crates/warzone-protocol/src/sender_keys.rs
Normal file
210
warzone/crates/warzone-protocol/src/sender_keys.rs
Normal file
@@ -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<Vec<u8>, 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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ fn extract_message_id(data: &[u8]) -> Option<String> {
|
|||||||
WireMessage::FileHeader { id, .. } => Some(id),
|
WireMessage::FileHeader { id, .. } => Some(id),
|
||||||
WireMessage::FileChunk { id, .. } => Some(id),
|
WireMessage::FileChunk { id, .. } => Some(id),
|
||||||
WireMessage::Receipt { message_id, .. } => Some(message_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 {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ fn extract_message_id(data: &[u8]) -> Option<String> {
|
|||||||
WireMessage::FileHeader { id, .. } => Some(id),
|
WireMessage::FileHeader { id, .. } => Some(id),
|
||||||
WireMessage::FileChunk { id, .. } => Some(id),
|
WireMessage::FileChunk { id, .. } => Some(id),
|
||||||
WireMessage::Receipt { message_id, .. } => Some(message_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 {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
Reference in New Issue
Block a user