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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ pub mod message;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
pub mod history;
|
||||
pub mod sender_keys;
|
||||
|
||||
@@ -84,4 +84,21 @@ pub enum WireMessage {
|
||||
total_chunks: u32,
|
||||
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::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
|
||||
|
||||
@@ -30,6 +30,10 @@ fn extract_message_id(data: &[u8]) -> Option<String> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user