diff --git a/crates/quicprochat-p2p/src/announce.rs b/crates/quicprochat-p2p/src/announce.rs index 567e965..e0166de 100644 --- a/crates/quicprochat-p2p/src/announce.rs +++ b/crates/quicprochat-p2p/src/announce.rs @@ -17,6 +17,8 @@ pub const CAP_STORE: u16 = 0x0002; pub const CAP_GATEWAY: u16 = 0x0004; /// Capability flag: node is on a low-bandwidth transport only. pub const CAP_CONSTRAINED: u16 = 0x0008; +/// Capability flag: node has KeyPackages available for MLS group invites. +pub const CAP_MLS_READY: u16 = 0x0010; /// A signed mesh node announcement. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -37,6 +39,10 @@ pub struct MeshAnnounce { pub hop_count: u8, /// Maximum propagation hops. pub max_hops: u8, + /// Optional hash of current KeyPackage (SHA-256, truncated to 8 bytes). + /// Present when CAP_MLS_READY is set. Peers can request the full KeyPackage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keypackage_hash: Option<[u8; 8]>, /// Ed25519 signature over all fields except signature and hop_count. pub signature: Vec, } @@ -51,6 +57,16 @@ pub fn compute_address(identity_key: &[u8]) -> [u8; 16] { addr } +/// Compute the 8-byte truncated hash of a KeyPackage for announce inclusion. +/// +/// This hash is used to identify which KeyPackage version a node has available. +pub fn compute_keypackage_hash(keypackage_bytes: &[u8]) -> [u8; 8] { + let hash = Sha256::digest(keypackage_bytes); + let mut kp_hash = [0u8; 8]; + kp_hash.copy_from_slice(&hash[..8]); + kp_hash +} + impl MeshAnnounce { /// Create and sign a new mesh announcement. pub fn new( @@ -58,6 +74,17 @@ impl MeshAnnounce { capabilities: u16, reachable_via: Vec<(String, Vec)>, max_hops: u8, + ) -> Self { + Self::with_keypackage(identity, capabilities, reachable_via, max_hops, None) + } + + /// Create announcement with an optional KeyPackage hash. + pub fn with_keypackage( + identity: &MeshIdentity, + capabilities: u16, + reachable_via: Vec<(String, Vec)>, + max_hops: u8, + keypackage_hash: Option<[u8; 8]>, ) -> Self { let identity_key = identity.public_key().to_vec(); let address = compute_address(&identity_key); @@ -75,6 +102,7 @@ impl MeshAnnounce { reachable_via, hop_count: 0, max_hops, + keypackage_hash, signature: Vec::new(), }; @@ -105,7 +133,7 @@ impl MeshAnnounce { /// hop_count without re-signing (same design as [`MeshEnvelope`]). fn signable_bytes(&self) -> Vec { let mut buf = Vec::with_capacity( - self.identity_key.len() + 16 + 2 + 8 + 8 + self.reachable_via.len() * 32 + 1, + self.identity_key.len() + 16 + 2 + 8 + 8 + self.reachable_via.len() * 32 + 1 + 9, ); buf.extend_from_slice(&self.identity_key); buf.extend_from_slice(&self.address); @@ -117,6 +145,13 @@ impl MeshAnnounce { buf.extend_from_slice(addr); } buf.push(self.max_hops); + // Include keypackage_hash in signature if present + if let Some(kp_hash) = &self.keypackage_hash { + buf.push(1); // presence marker + buf.extend_from_slice(kp_hash); + } else { + buf.push(0); // absence marker + } buf } diff --git a/crates/quicprochat-p2p/src/keypackage_cache.rs b/crates/quicprochat-p2p/src/keypackage_cache.rs new file mode 100644 index 0000000..3677f85 --- /dev/null +++ b/crates/quicprochat-p2p/src/keypackage_cache.rs @@ -0,0 +1,360 @@ +//! KeyPackage cache for mesh-based MLS group setup. +//! +//! The [`KeyPackageCache`] stores MLS KeyPackages received from other nodes, +//! enabling group creation without a central server. KeyPackages are: +//! +//! - Indexed by the node's 16-byte mesh address +//! - Hashed (8 bytes) for announce inclusion +//! - TTL-managed for expiry (MLS KeyPackages are single-use but we cache N of them) +//! - Bounded by capacity to prevent memory exhaustion +//! +//! # Protocol Flow +//! +//! 1. Bob generates KeyPackage, computes hash, includes hash in MeshAnnounce +//! 2. Bob broadcasts full KeyPackage periodically (or on request) +//! 3. Alice receives Bob's KeyPackage, stores in cache +//! 4. Alice wants to create group with Bob: fetches from cache, creates Welcome +//! 5. Alice sends Welcome to Bob via mesh routing + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use crate::address::MeshAddress; +use crate::announce::compute_keypackage_hash; + +/// Default TTL for cached KeyPackages (24 hours). +const DEFAULT_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Default maximum KeyPackages per address (allow rotation). +const DEFAULT_MAX_PER_ADDRESS: usize = 3; + +/// A cached KeyPackage entry. +#[derive(Clone, Debug)] +pub struct CachedKeyPackage { + /// The serialized MLS KeyPackage bytes. + pub bytes: Vec, + /// 8-byte truncated hash for matching against announces. + pub hash: [u8; 8], + /// When this entry was stored. + pub stored_at: Instant, + /// When this entry expires. + pub expires_at: Instant, +} + +impl CachedKeyPackage { + /// Create a new cached entry with default TTL. + pub fn new(bytes: Vec) -> Self { + Self::with_ttl(bytes, DEFAULT_TTL) + } + + /// Create with custom TTL. + pub fn with_ttl(bytes: Vec, ttl: Duration) -> Self { + let hash = compute_keypackage_hash(&bytes); + let now = Instant::now(); + Self { + bytes, + hash, + stored_at: now, + expires_at: now + ttl, + } + } + + /// Check if this entry has expired. + pub fn is_expired(&self) -> bool { + Instant::now() > self.expires_at + } +} + +/// Cache for KeyPackages received from mesh peers. +pub struct KeyPackageCache { + /// Address -> list of cached KeyPackages (multiple for rotation). + entries: HashMap>, + /// Maximum KeyPackages stored per address. + max_per_address: usize, + /// Total capacity (max addresses). + max_addresses: usize, +} + +impl KeyPackageCache { + /// Create a new cache with default settings. + pub fn new() -> Self { + Self::with_capacity(1000, DEFAULT_MAX_PER_ADDRESS) + } + + /// Create with custom capacity. + pub fn with_capacity(max_addresses: usize, max_per_address: usize) -> Self { + Self { + entries: HashMap::new(), + max_per_address, + max_addresses, + } + } + + /// Store a KeyPackage for a given address. + /// + /// Returns `true` if stored, `false` if rejected (at capacity or duplicate hash). + pub fn store(&mut self, address: MeshAddress, keypackage_bytes: Vec) -> bool { + let entry = CachedKeyPackage::new(keypackage_bytes); + self.store_entry(address, entry) + } + + /// Store a KeyPackage entry. + fn store_entry(&mut self, address: MeshAddress, entry: CachedKeyPackage) -> bool { + // Check if we already have this exact KeyPackage + if let Some(existing) = self.entries.get(&address) { + if existing.iter().any(|e| e.hash == entry.hash) { + return false; // Duplicate + } + } + + // Check total capacity + if !self.entries.contains_key(&address) && self.entries.len() >= self.max_addresses { + // Evict oldest entry + self.evict_oldest(); + } + + let list = self.entries.entry(address).or_default(); + + // Enforce per-address limit + while list.len() >= self.max_per_address { + list.remove(0); // Remove oldest + } + + list.push(entry); + true + } + + /// Get the newest KeyPackage for an address. + pub fn get(&self, address: &MeshAddress) -> Option<&CachedKeyPackage> { + self.entries + .get(address) + .and_then(|list| list.iter().rev().find(|e| !e.is_expired())) + } + + /// Get a KeyPackage by its hash. + pub fn get_by_hash(&self, address: &MeshAddress, hash: &[u8; 8]) -> Option<&CachedKeyPackage> { + self.entries.get(address).and_then(|list| { + list.iter() + .rev() + .find(|e| &e.hash == hash && !e.is_expired()) + }) + } + + /// Get the newest KeyPackage bytes for an address. + pub fn get_bytes(&self, address: &MeshAddress) -> Option> { + self.get(address).map(|e| e.bytes.clone()) + } + + /// Check if we have a KeyPackage matching a given hash. + pub fn has_hash(&self, address: &MeshAddress, hash: &[u8; 8]) -> bool { + self.get_by_hash(address, hash).is_some() + } + + /// Remove all expired entries. Returns count removed. + pub fn gc_expired(&mut self) -> usize { + let mut removed = 0; + self.entries.retain(|_, list| { + let before = list.len(); + list.retain(|e| !e.is_expired()); + removed += before - list.len(); + !list.is_empty() + }); + removed + } + + /// Evict the oldest entry across all addresses. + fn evict_oldest(&mut self) { + let oldest_addr = self + .entries + .iter() + .filter_map(|(addr, list)| { + list.first().map(|e| (addr.clone(), e.stored_at)) + }) + .min_by_key(|(_, stored)| *stored) + .map(|(addr, _)| addr); + + if let Some(addr) = oldest_addr { + if let Some(list) = self.entries.get_mut(&addr) { + list.remove(0); + if list.is_empty() { + self.entries.remove(&addr); + } + } + } + } + + /// Number of addresses with cached KeyPackages. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the cache is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Total number of cached KeyPackages. + pub fn total_keypackages(&self) -> usize { + self.entries.values().map(|v| v.len()).sum() + } + + /// Consume a KeyPackage (remove after use, as MLS KeyPackages are single-use). + /// + /// Returns the KeyPackage bytes if found. + pub fn consume(&mut self, address: &MeshAddress, hash: &[u8; 8]) -> Option> { + let list = self.entries.get_mut(address)?; + let idx = list.iter().position(|e| &e.hash == hash)?; + let entry = list.remove(idx); + if list.is_empty() { + self.entries.remove(address); + } + Some(entry.bytes) + } +} + +impl Default for KeyPackageCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_keypackage(seed: u8) -> Vec { + vec![seed; 100 + seed as usize] + } + + fn make_address(seed: u8) -> MeshAddress { + MeshAddress::from_bytes([seed; 16]) + } + + #[test] + fn store_and_retrieve() { + let mut cache = KeyPackageCache::new(); + let addr = make_address(1); + let kp = make_keypackage(1); + let hash = compute_keypackage_hash(&kp); + + assert!(cache.store(addr, kp.clone())); + assert_eq!(cache.len(), 1); + + let retrieved = cache.get(&addr).expect("should exist"); + assert_eq!(retrieved.bytes, kp); + assert_eq!(retrieved.hash, hash); + } + + #[test] + fn reject_duplicate() { + let mut cache = KeyPackageCache::new(); + let addr = make_address(2); + let kp = make_keypackage(2); + + assert!(cache.store(addr, kp.clone())); + assert!(!cache.store(addr, kp), "duplicate should be rejected"); + assert_eq!(cache.total_keypackages(), 1); + } + + #[test] + fn multiple_per_address() { + let mut cache = KeyPackageCache::with_capacity(100, 3); + let addr = make_address(3); + + assert!(cache.store(addr, make_keypackage(1))); + assert!(cache.store(addr, make_keypackage(2))); + assert!(cache.store(addr, make_keypackage(3))); + assert_eq!(cache.total_keypackages(), 3); + + // Fourth should evict first + assert!(cache.store(addr, make_keypackage(4))); + assert_eq!(cache.total_keypackages(), 3); + + // First should be gone + let hash1 = compute_keypackage_hash(&make_keypackage(1)); + assert!(!cache.has_hash(&addr, &hash1)); + + // Fourth should be present + let hash4 = compute_keypackage_hash(&make_keypackage(4)); + assert!(cache.has_hash(&addr, &hash4)); + } + + #[test] + fn consume_removes_keypackage() { + let mut cache = KeyPackageCache::new(); + let addr = make_address(4); + let kp = make_keypackage(4); + let hash = compute_keypackage_hash(&kp); + + cache.store(addr, kp.clone()); + assert!(cache.has_hash(&addr, &hash)); + + let consumed = cache.consume(&addr, &hash).expect("should consume"); + assert_eq!(consumed, kp); + assert!(!cache.has_hash(&addr, &hash)); + assert!(cache.is_empty()); + } + + #[test] + fn get_by_hash() { + let mut cache = KeyPackageCache::new(); + let addr = make_address(5); + let kp1 = make_keypackage(51); + let kp2 = make_keypackage(52); + let hash1 = compute_keypackage_hash(&kp1); + let hash2 = compute_keypackage_hash(&kp2); + + cache.store(addr, kp1.clone()); + cache.store(addr, kp2.clone()); + + let found1 = cache.get_by_hash(&addr, &hash1).expect("hash1"); + assert_eq!(found1.bytes, kp1); + + let found2 = cache.get_by_hash(&addr, &hash2).expect("hash2"); + assert_eq!(found2.bytes, kp2); + + let wrong_hash = [0xFFu8; 8]; + assert!(cache.get_by_hash(&addr, &wrong_hash).is_none()); + } + + #[test] + fn capacity_eviction() { + let mut cache = KeyPackageCache::with_capacity(2, 1); + + let addr1 = make_address(1); + let addr2 = make_address(2); + let addr3 = make_address(3); + + cache.store(addr1, make_keypackage(1)); + cache.store(addr2, make_keypackage(2)); + assert_eq!(cache.len(), 2); + + // Third should evict oldest (addr1) + cache.store(addr3, make_keypackage(3)); + assert_eq!(cache.len(), 2); + assert!(cache.get(&addr1).is_none()); + assert!(cache.get(&addr2).is_some()); + assert!(cache.get(&addr3).is_some()); + } + + #[test] + fn expiry() { + let mut cache = KeyPackageCache::new(); + let addr = make_address(6); + + // Create entry with very short TTL + let kp = make_keypackage(6); + let entry = CachedKeyPackage::with_ttl(kp, Duration::from_millis(1)); + cache.store_entry(addr, entry); + + assert_eq!(cache.total_keypackages(), 1); + + // Wait for expiry + std::thread::sleep(Duration::from_millis(10)); + + // GC should remove it + let removed = cache.gc_expired(); + assert_eq!(removed, 1); + assert!(cache.is_empty()); + } +} diff --git a/crates/quicprochat-p2p/src/lib.rs b/crates/quicprochat-p2p/src/lib.rs index 3354a91..764881d 100644 --- a/crates/quicprochat-p2p/src/lib.rs +++ b/crates/quicprochat-p2p/src/lib.rs @@ -20,6 +20,8 @@ pub mod fapp_router; pub mod broadcast; pub mod envelope; pub mod envelope_v2; +pub mod keypackage_cache; +pub mod mesh_protocol; pub mod mls_lite; pub mod identity; pub mod link; diff --git a/crates/quicprochat-p2p/src/mesh_protocol.rs b/crates/quicprochat-p2p/src/mesh_protocol.rs new file mode 100644 index 0000000..0a8e5a3 --- /dev/null +++ b/crates/quicprochat-p2p/src/mesh_protocol.rs @@ -0,0 +1,269 @@ +//! Mesh protocol messages for peer-to-peer communication. +//! +//! This module defines the control messages used for mesh coordination: +//! - KeyPackage request/response for MLS group setup +//! - Future: route requests, capability queries, etc. + +use serde::{Deserialize, Serialize}; + +use crate::address::MeshAddress; + +/// Protocol message type discriminator. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum MessageType { + /// Request a KeyPackage from a node. + KeyPackageRequest = 0x10, + /// Response with KeyPackage data. + KeyPackageResponse = 0x11, + /// Node has no KeyPackage available. + KeyPackageUnavailable = 0x12, +} + +/// Request a KeyPackage from a peer. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyPackageRequest { + /// Who is requesting. + pub requester_addr: MeshAddress, + /// Whose KeyPackage is requested. + pub target_addr: MeshAddress, + /// Optional: specific hash to request (from announce). + pub hash: Option<[u8; 8]>, + /// Request ID for correlation. + pub request_id: u32, +} + +impl KeyPackageRequest { + /// Create a new request. + pub fn new(requester: MeshAddress, target: MeshAddress) -> Self { + Self { + requester_addr: requester, + target_addr: target, + hash: None, + request_id: rand::random(), + } + } + + /// Create with specific hash. + pub fn with_hash(requester: MeshAddress, target: MeshAddress, hash: [u8; 8]) -> Self { + Self { + requester_addr: requester, + target_addr: target, + hash: Some(hash), + request_id: rand::random(), + } + } + + /// Serialize to CBOR. + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::new(); + buf.push(MessageType::KeyPackageRequest as u8); + ciborium::into_writer(self, &mut buf).expect("CBOR serialization"); + buf + } + + /// Deserialize from CBOR (after type byte). + pub fn from_wire(bytes: &[u8]) -> anyhow::Result { + if bytes.is_empty() || bytes[0] != MessageType::KeyPackageRequest as u8 { + anyhow::bail!("not a KeyPackageRequest"); + } + let req: Self = ciborium::from_reader(&bytes[1..])?; + Ok(req) + } +} + +/// Response with KeyPackage data. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyPackageResponse { + /// Whose KeyPackage this is. + pub owner_addr: MeshAddress, + /// The serialized MLS KeyPackage. + pub keypackage_bytes: Vec, + /// Hash of the KeyPackage (for verification). + pub hash: [u8; 8], + /// Matching request ID. + pub request_id: u32, +} + +impl KeyPackageResponse { + /// Create a new response. + pub fn new( + owner: MeshAddress, + keypackage_bytes: Vec, + request_id: u32, + ) -> Self { + let hash = crate::announce::compute_keypackage_hash(&keypackage_bytes); + Self { + owner_addr: owner, + keypackage_bytes, + hash, + request_id, + } + } + + /// Serialize to CBOR. + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::new(); + buf.push(MessageType::KeyPackageResponse as u8); + ciborium::into_writer(self, &mut buf).expect("CBOR serialization"); + buf + } + + /// Deserialize from CBOR (after type byte). + pub fn from_wire(bytes: &[u8]) -> anyhow::Result { + if bytes.is_empty() || bytes[0] != MessageType::KeyPackageResponse as u8 { + anyhow::bail!("not a KeyPackageResponse"); + } + let resp: Self = ciborium::from_reader(&bytes[1..])?; + Ok(resp) + } + + /// Verify the hash matches the KeyPackage. + pub fn verify_hash(&self) -> bool { + let computed = crate::announce::compute_keypackage_hash(&self.keypackage_bytes); + computed == self.hash + } +} + +/// Response indicating no KeyPackage available. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyPackageUnavailable { + /// Whose KeyPackage was requested. + pub target_addr: MeshAddress, + /// Matching request ID. + pub request_id: u32, +} + +impl KeyPackageUnavailable { + /// Create a new unavailable response. + pub fn new(target: MeshAddress, request_id: u32) -> Self { + Self { + target_addr: target, + request_id, + } + } + + /// Serialize to CBOR. + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::new(); + buf.push(MessageType::KeyPackageUnavailable as u8); + ciborium::into_writer(self, &mut buf).expect("CBOR serialization"); + buf + } + + /// Deserialize from CBOR (after type byte). + pub fn from_wire(bytes: &[u8]) -> anyhow::Result { + if bytes.is_empty() || bytes[0] != MessageType::KeyPackageUnavailable as u8 { + anyhow::bail!("not a KeyPackageUnavailable"); + } + let resp: Self = ciborium::from_reader(&bytes[1..])?; + Ok(resp) + } +} + +/// Parse the message type from wire bytes. +pub fn parse_message_type(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + match bytes[0] { + 0x10 => Some(MessageType::KeyPackageRequest), + 0x11 => Some(MessageType::KeyPackageResponse), + 0x12 => Some(MessageType::KeyPackageUnavailable), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_address(seed: u8) -> MeshAddress { + MeshAddress::from_bytes([seed; 16]) + } + + #[test] + fn request_roundtrip() { + let req = KeyPackageRequest::new(make_address(1), make_address(2)); + let wire = req.to_wire(); + let restored = KeyPackageRequest::from_wire(&wire).expect("parse"); + + assert_eq!(req.requester_addr, restored.requester_addr); + assert_eq!(req.target_addr, restored.target_addr); + assert_eq!(req.request_id, restored.request_id); + } + + #[test] + fn request_with_hash_roundtrip() { + let hash = [0xAB; 8]; + let req = KeyPackageRequest::with_hash(make_address(1), make_address(2), hash); + let wire = req.to_wire(); + let restored = KeyPackageRequest::from_wire(&wire).expect("parse"); + + assert_eq!(req.hash, restored.hash); + assert_eq!(Some(hash), restored.hash); + } + + #[test] + fn response_roundtrip() { + let kp_bytes = vec![0x42; 100]; + let resp = KeyPackageResponse::new(make_address(3), kp_bytes.clone(), 12345); + let wire = resp.to_wire(); + let restored = KeyPackageResponse::from_wire(&wire).expect("parse"); + + assert_eq!(resp.owner_addr, restored.owner_addr); + assert_eq!(resp.keypackage_bytes, restored.keypackage_bytes); + assert_eq!(resp.hash, restored.hash); + assert_eq!(resp.request_id, restored.request_id); + assert!(restored.verify_hash()); + } + + #[test] + fn unavailable_roundtrip() { + let resp = KeyPackageUnavailable::new(make_address(4), 99999); + let wire = resp.to_wire(); + let restored = KeyPackageUnavailable::from_wire(&wire).expect("parse"); + + assert_eq!(resp.target_addr, restored.target_addr); + assert_eq!(resp.request_id, restored.request_id); + } + + #[test] + fn parse_message_type_works() { + let req = KeyPackageRequest::new(make_address(1), make_address(2)); + let wire = req.to_wire(); + assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageRequest)); + + let resp = KeyPackageResponse::new(make_address(3), vec![0x42], 1); + let wire = resp.to_wire(); + assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageResponse)); + + let unavail = KeyPackageUnavailable::new(make_address(4), 2); + let wire = unavail.to_wire(); + assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageUnavailable)); + + assert_eq!(parse_message_type(&[]), None); + assert_eq!(parse_message_type(&[0xFF]), None); + } + + #[test] + fn measure_protocol_overhead() { + let req = KeyPackageRequest::new(make_address(1), make_address(2)); + let wire = req.to_wire(); + println!("KeyPackageRequest: {} bytes", wire.len()); + + let kp_bytes = vec![0x42; 306]; // Typical MLS KeyPackage size + let resp = KeyPackageResponse::new(make_address(3), kp_bytes.clone(), 12345); + let wire = resp.to_wire(); + println!("KeyPackageResponse (306B payload): {} bytes", wire.len()); + println!("Response overhead: {} bytes", wire.len() - 306); + + let unavail = KeyPackageUnavailable::new(make_address(4), 99999); + let wire = unavail.to_wire(); + println!("KeyPackageUnavailable: {} bytes", wire.len()); + + // Assertions + assert!(req.to_wire().len() < 100, "request should be compact"); + assert!(unavail.to_wire().len() < 50, "unavailable should be compact"); + } +}