From 9cbf824db61124f9314aa0faf68082e1e6f71bec Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Mon, 30 Mar 2026 23:46:24 +0200 Subject: [PATCH] feat(mesh): add MeshEnvelopeV2 with truncated 16-byte addresses S5: Compact envelope format for constrained links: - 16-byte truncated addresses (MeshAddress) instead of 32-byte keys - 16-byte truncated content ID - u16 TTL and u32 timestamp (smaller than V1) - Priority field (Low/Normal/High/Emergency) - ~30-50 bytes savings per envelope vs V1 Full public keys are exchanged during announce phase and cached in routing table. Envelope only needs addresses for routing. --- crates/quicprochat-p2p/src/envelope_v2.rs | 432 ++++++++++++++++++++++ crates/quicprochat-p2p/src/lib.rs | 1 + 2 files changed, 433 insertions(+) create mode 100644 crates/quicprochat-p2p/src/envelope_v2.rs diff --git a/crates/quicprochat-p2p/src/envelope_v2.rs b/crates/quicprochat-p2p/src/envelope_v2.rs new file mode 100644 index 0000000..49c1d05 --- /dev/null +++ b/crates/quicprochat-p2p/src/envelope_v2.rs @@ -0,0 +1,432 @@ +//! Compact mesh envelope using truncated 16-byte addresses. +//! +//! [`MeshEnvelopeV2`] is a bandwidth-optimized envelope format for constrained +//! links (LoRa, serial). It uses [`MeshAddress`] (16 bytes) instead of full +//! 32-byte public keys, saving 32 bytes per envelope. +//! +//! Full public keys are exchanged during the announce phase and cached in the +//! routing table. The envelope only needs addresses for routing. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::address::MeshAddress; +use crate::identity::MeshIdentity; + +/// Default maximum hops for mesh forwarding. +const DEFAULT_MAX_HOPS: u8 = 5; + +/// Version byte for envelope format detection. +const ENVELOPE_V2_VERSION: u8 = 0x02; + +/// Priority levels for mesh routing. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum Priority { + /// Lowest priority (announce, telemetry). + Low = 0, + /// Normal priority (regular messages). + Normal = 1, + /// High priority (important messages). + High = 2, + /// Emergency priority (always forwarded first). + Emergency = 3, +} + +impl Default for Priority { + fn default() -> Self { + Self::Normal + } +} + +impl From for Priority { + fn from(v: u8) -> Self { + match v { + 0 => Self::Low, + 1 => Self::Normal, + 2 => Self::High, + 3 => Self::Emergency, + _ => Self::Normal, + } + } +} + +/// Compact mesh envelope with 16-byte truncated addresses. +/// +/// # Wire overhead +/// +/// - Version: 1 byte +/// - Flags: 1 byte (priority: 2 bits, reserved: 6 bits) +/// - ID: 16 bytes (truncated from 32) +/// - Sender: 16 bytes +/// - Recipient: 16 bytes (or 0 for broadcast) +/// - TTL: 2 bytes (u16, max ~18 hours) +/// - Hop count: 1 byte +/// - Max hops: 1 byte +/// - Timestamp: 4 bytes (u32, seconds since epoch mod 2^32) +/// - Signature: 64 bytes +/// - Payload: variable +/// +/// **Total fixed overhead: ~122 bytes** (vs ~174 for V1 with full keys) +/// Savings: ~52 bytes per envelope +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MeshEnvelopeV2 { + /// Format version (0x02 for V2). + pub version: u8, + /// Flags byte: bits 0-1 = priority, bits 2-7 reserved. + pub flags: u8, + /// 16-byte truncated content ID (for deduplication). + pub id: [u8; 16], + /// 16-byte truncated sender address. + pub sender_addr: MeshAddress, + /// 16-byte truncated recipient address (BROADCAST for all). + pub recipient_addr: MeshAddress, + /// Encrypted payload (opaque to mesh layer). + pub payload: Vec, + /// Time-to-live in seconds (u16, max 65535 = ~18 hours). + pub ttl_secs: u16, + /// Current hop count. + pub hop_count: u8, + /// Maximum hops before drop. + pub max_hops: u8, + /// Unix timestamp (seconds, truncated to u32). + pub timestamp: u32, + /// Ed25519 signature (64 bytes). + pub signature: [u8; 64], +} + +impl MeshEnvelopeV2 { + /// Create and sign a new compact mesh envelope. + pub fn new( + identity: &MeshIdentity, + recipient_addr: MeshAddress, + payload: Vec, + ttl_secs: u16, + max_hops: u8, + priority: Priority, + ) -> Self { + let sender_addr = MeshAddress::from_public_key(&identity.public_key()); + let hop_count = 0u8; + let max_hops = if max_hops == 0 { DEFAULT_MAX_HOPS } else { max_hops }; + let timestamp = (SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() & 0xFFFF_FFFF) as u32; + + let id = Self::compute_id( + &sender_addr, + &recipient_addr, + &payload, + ttl_secs, + max_hops, + timestamp, + ); + + let flags = (priority as u8) & 0x03; + + let mut envelope = Self { + version: ENVELOPE_V2_VERSION, + flags, + id, + sender_addr, + recipient_addr, + payload, + ttl_secs, + hop_count, + max_hops, + timestamp, + signature: [0u8; 64], + }; + + let signable = envelope.signable_bytes(); + let sig = identity.sign(&signable); + envelope.signature = sig; + + envelope + } + + /// Create for broadcast (recipient = all zeros). + pub fn broadcast( + identity: &MeshIdentity, + payload: Vec, + ttl_secs: u16, + max_hops: u8, + priority: Priority, + ) -> Self { + Self::new(identity, MeshAddress::BROADCAST, payload, ttl_secs, max_hops, priority) + } + + /// Compute the 16-byte truncated content ID. + fn compute_id( + sender_addr: &MeshAddress, + recipient_addr: &MeshAddress, + payload: &[u8], + ttl_secs: u16, + max_hops: u8, + timestamp: u32, + ) -> [u8; 16] { + let mut hasher = Sha256::new(); + hasher.update(sender_addr.as_bytes()); + hasher.update(recipient_addr.as_bytes()); + hasher.update(payload); + hasher.update(ttl_secs.to_le_bytes()); + hasher.update([max_hops]); + hasher.update(timestamp.to_le_bytes()); + let hash = hasher.finalize(); + let mut id = [0u8; 16]; + id.copy_from_slice(&hash[..16]); + id + } + + /// Bytes to sign/verify (excludes signature and hop_count). + fn signable_bytes(&self) -> Vec { + let mut buf = Vec::with_capacity(64 + self.payload.len()); + buf.push(self.version); + buf.push(self.flags); + buf.extend_from_slice(&self.id); + buf.extend_from_slice(self.sender_addr.as_bytes()); + buf.extend_from_slice(self.recipient_addr.as_bytes()); + buf.extend_from_slice(&self.payload); + buf.extend_from_slice(&self.ttl_secs.to_le_bytes()); + buf.push(self.max_hops); + buf.extend_from_slice(&self.timestamp.to_le_bytes()); + buf + } + + /// Verify the signature using the sender's full public key. + /// + /// The caller must have the sender's full key (from announce/routing table). + pub fn verify_with_key(&self, sender_public_key: &[u8; 32]) -> bool { + // First check that the address matches the key + if !self.sender_addr.matches_key(sender_public_key) { + return false; + } + let signable = self.signable_bytes(); + quicprochat_core::IdentityKeypair::verify_raw(sender_public_key, &signable, &self.signature) + .is_ok() + } + + /// Get the priority level. + pub fn priority(&self) -> Priority { + Priority::from(self.flags & 0x03) + } + + /// Check if broadcast (recipient is all zeros). + pub fn is_broadcast(&self) -> bool { + self.recipient_addr.is_broadcast() + } + + /// Check if expired. + pub fn is_expired(&self) -> bool { + let now = (SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() & 0xFFFF_FFFF) as u32; + // Handle u32 wraparound (every ~136 years) + let elapsed = now.wrapping_sub(self.timestamp); + elapsed > self.ttl_secs as u32 + } + + /// Can this envelope be forwarded? + pub fn can_forward(&self) -> bool { + self.hop_count < self.max_hops && !self.is_expired() + } + + /// Create a forwarded copy with hop_count incremented. + pub fn forwarded(&self) -> Self { + let mut copy = self.clone(); + copy.hop_count = copy.hop_count.saturating_add(1); + copy + } + + /// Serialize to compact CBOR. + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::new(); + ciborium::into_writer(self, &mut buf).expect("CBOR serialization should not fail"); + buf + } + + /// Deserialize from CBOR. + pub fn from_wire(bytes: &[u8]) -> anyhow::Result { + let env: Self = ciborium::from_reader(bytes)?; + if env.version != ENVELOPE_V2_VERSION { + anyhow::bail!("unexpected envelope version: {}", env.version); + } + Ok(env) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_identity() -> MeshIdentity { + MeshIdentity::generate() + } + + #[test] + fn create_and_verify() { + let id = test_identity(); + let recipient_key = [0xBBu8; 32]; + let recipient_addr = MeshAddress::from_public_key(&recipient_key); + + let env = MeshEnvelopeV2::new( + &id, + recipient_addr, + b"hello compact".to_vec(), + 3600, + 5, + Priority::Normal, + ); + + assert_eq!(env.version, ENVELOPE_V2_VERSION); + assert_eq!(env.hop_count, 0); + assert!(env.verify_with_key(&id.public_key())); + assert!(!env.is_expired()); + assert!(env.can_forward()); + } + + #[test] + fn broadcast_envelope() { + let id = test_identity(); + let env = MeshEnvelopeV2::broadcast( + &id, + b"announcement".to_vec(), + 300, + 8, + Priority::Low, + ); + + assert!(env.is_broadcast()); + assert_eq!(env.priority(), Priority::Low); + assert!(env.verify_with_key(&id.public_key())); + } + + #[test] + fn forwarded_still_verifies() { + let id = test_identity(); + let env = MeshEnvelopeV2::new( + &id, + MeshAddress::from_bytes([0xCC; 16]), + b"forward me".to_vec(), + 3600, + 5, + Priority::High, + ); + + let fwd = env.forwarded(); + assert_eq!(fwd.hop_count, 1); + assert!(fwd.verify_with_key(&id.public_key())); + + let fwd2 = fwd.forwarded(); + assert_eq!(fwd2.hop_count, 2); + assert!(fwd2.verify_with_key(&id.public_key())); + } + + #[test] + fn cbor_roundtrip() { + let id = test_identity(); + let env = MeshEnvelopeV2::new( + &id, + MeshAddress::from_bytes([0xDD; 16]), + b"roundtrip test".to_vec(), + 1800, + 4, + Priority::Emergency, + ); + + let wire = env.to_wire(); + let restored = MeshEnvelopeV2::from_wire(&wire).expect("deserialize"); + + assert_eq!(env.id, restored.id); + assert_eq!(env.sender_addr, restored.sender_addr); + assert_eq!(env.recipient_addr, restored.recipient_addr); + assert_eq!(env.payload, restored.payload); + assert_eq!(env.ttl_secs, restored.ttl_secs); + assert_eq!(env.hop_count, restored.hop_count); + assert_eq!(env.max_hops, restored.max_hops); + assert_eq!(env.timestamp, restored.timestamp); + assert_eq!(env.signature, restored.signature); + assert_eq!(env.priority(), Priority::Emergency); + } + + #[test] + fn measure_v2_overhead() { + let id = test_identity(); + let recipient_addr = MeshAddress::from_bytes([0xEE; 16]); + + println!("=== MeshEnvelopeV2 Wire Overhead (CBOR) ==="); + + // Empty payload + let env_empty = MeshEnvelopeV2::new(&id, recipient_addr, vec![], 3600, 5, Priority::Normal); + let wire_empty = env_empty.to_wire(); + println!("Payload 0B: wire {} bytes (overhead: {} bytes)", wire_empty.len(), wire_empty.len()); + let v2_overhead = wire_empty.len(); + + // Compare to V1 + let v1_env = crate::envelope::MeshEnvelope::new( + &id, + &[0xEE; 32], + vec![], + 3600, + 5, + ); + let v1_wire = v1_env.to_wire(); + println!("V1 empty: {} bytes", v1_wire.len()); + println!("V2 savings: {} bytes ({:.1}%)", + v1_wire.len() - v2_overhead, + ((v1_wire.len() - v2_overhead) as f64 / v1_wire.len() as f64) * 100.0); + + // 10-byte payload + let env_10 = MeshEnvelopeV2::new(&id, recipient_addr, b"hello mesh".to_vec(), 3600, 5, Priority::Normal); + let wire_10 = env_10.to_wire(); + println!("Payload 10B: wire {} bytes", wire_10.len()); + + // 100-byte payload + let env_100 = MeshEnvelopeV2::new(&id, recipient_addr, vec![0x42; 100], 3600, 5, Priority::Normal); + let wire_100 = env_100.to_wire(); + println!("Payload 100B: wire {} bytes", wire_100.len()); + + // V2 should save ~30-50 bytes due to truncated addresses and IDs + assert!(v2_overhead < v1_wire.len(), "V2 should be smaller than V1"); + assert!(v2_overhead < 150, "V2 overhead should be under 150 bytes"); + } + + #[test] + fn wrong_key_fails_verification() { + let id = test_identity(); + let env = MeshEnvelopeV2::new( + &id, + MeshAddress::from_bytes([0xFF; 16]), + b"verify me".to_vec(), + 3600, + 5, + Priority::Normal, + ); + + // Wrong key should fail + let wrong_key = [0x42u8; 32]; + assert!(!env.verify_with_key(&wrong_key)); + + // Correct key should pass + assert!(env.verify_with_key(&id.public_key())); + } + + #[test] + fn priority_levels() { + let id = test_identity(); + + for prio in [Priority::Low, Priority::Normal, Priority::High, Priority::Emergency] { + let env = MeshEnvelopeV2::new( + &id, + MeshAddress::BROADCAST, + b"prio test".to_vec(), + 60, + 3, + prio, + ); + assert_eq!(env.priority(), prio); + } + } +} diff --git a/crates/quicprochat-p2p/src/lib.rs b/crates/quicprochat-p2p/src/lib.rs index 5dfbb16..fa8c705 100644 --- a/crates/quicprochat-p2p/src/lib.rs +++ b/crates/quicprochat-p2p/src/lib.rs @@ -17,6 +17,7 @@ pub mod announce; pub mod announce_protocol; pub mod broadcast; pub mod envelope; +pub mod envelope_v2; pub mod identity; pub mod link; pub mod mesh_router;