diff --git a/crates/quicprochat-core/src/group.rs b/crates/quicprochat-core/src/group.rs index c7c0d19..66fcaa7 100644 --- a/crates/quicprochat-core/src/group.rs +++ b/crates/quicprochat-core/src/group.rs @@ -1079,4 +1079,96 @@ mod tests { "send_message before join must return an error" ); } + + /// Measure actual MLS artifact sizes for mesh planning. + /// These numbers inform the MLS-Lite design and constrained link feasibility. + #[test] + fn measure_mls_wire_sizes() { + let creator_id = Arc::new(IdentityKeypair::generate()); + let joiner_id = Arc::new(IdentityKeypair::generate()); + + let mut creator = GroupMember::new(Arc::clone(&creator_id)); + let mut joiner = GroupMember::new(Arc::clone(&joiner_id)); + + // 1. KeyPackage size + let kp_bytes = joiner.generate_key_package().expect("generate KP"); + println!("=== MLS Wire Format Sizes ==="); + println!("KeyPackage: {} bytes", kp_bytes.len()); + + // 2. Create group (no wire message, just local state) + creator.create_group(b"size-test").expect("create group"); + + // 3. Add member -> Commit + Welcome + let (commit_bytes, welcome_bytes) = creator.add_member(&kp_bytes).expect("add member"); + println!("Commit (add): {} bytes", commit_bytes.len()); + println!("Welcome: {} bytes", welcome_bytes.len()); + + // Join the group + joiner.join_group(&welcome_bytes).expect("join"); + + // 4. Application message (short payload) + let short_msg = creator.send_message(b"hello").expect("short msg"); + println!("AppMessage (5B): {} bytes", short_msg.len()); + + // 5. Application message (medium payload ~100 bytes) + let medium_payload = vec![0x42u8; 100]; + let medium_msg = creator.send_message(&medium_payload).expect("medium msg"); + println!("AppMessage (100B): {} bytes", medium_msg.len()); + + // 6. Self-update proposal + let update_proposal = creator.propose_self_update().expect("update proposal"); + println!("UpdateProposal: {} bytes", update_proposal.len()); + + // Joiner processes the proposal + joiner.receive_message(&update_proposal).expect("recv proposal"); + + // 7. Commit (update only, no welcome) + let (update_commit, _) = joiner.commit_pending_proposals().expect("commit update"); + println!("Commit (update): {} bytes", update_commit.len()); + + // Summary for LoRa feasibility + println!("\n=== LoRa Feasibility (SF12/BW125, MTU=51 bytes) ==="); + println!("KeyPackage: {} fragments ({:.0}s at 1% duty)", + (kp_bytes.len() + 50) / 51, + (kp_bytes.len() as f64 / 51.0).ceil() * 36.0 / 60.0); + println!("Welcome: {} fragments ({:.0}s at 1% duty)", + (welcome_bytes.len() + 50) / 51, + (welcome_bytes.len() as f64 / 51.0).ceil() * 36.0 / 60.0); + println!("AppMessage (5B): {} fragments", + (short_msg.len() + 50) / 51); + + // Assertions to catch regressions / validate estimates + assert!(kp_bytes.len() < 1000, "KeyPackage should be under 1KB"); + assert!(welcome_bytes.len() < 3000, "Welcome should be under 3KB"); + assert!(short_msg.len() < 300, "Short AppMessage should be under 300B"); + } + + /// Measure MLS sizes with hybrid (post-quantum) mode enabled. + #[test] + fn measure_mls_wire_sizes_hybrid() { + let creator_id = Arc::new(IdentityKeypair::generate()); + let joiner_id = Arc::new(IdentityKeypair::generate()); + + let mut creator = GroupMember::new_hybrid(Arc::clone(&creator_id)); + let mut joiner = GroupMember::new_hybrid(Arc::clone(&joiner_id)); + + // KeyPackage with hybrid (X25519 + ML-KEM-768) init key + let kp_bytes = joiner.generate_key_package().expect("generate hybrid KP"); + println!("=== MLS Wire Format Sizes (Hybrid PQ Mode) ==="); + println!("KeyPackage (PQ): {} bytes", kp_bytes.len()); + + creator.create_group(b"hybrid-size-test").expect("create group"); + let (commit_bytes, welcome_bytes) = creator.add_member(&kp_bytes).expect("add member"); + println!("Commit (add, PQ): {} bytes", commit_bytes.len()); + println!("Welcome (PQ): {} bytes", welcome_bytes.len()); + + joiner.join_group(&welcome_bytes).expect("join"); + + let short_msg = creator.send_message(b"hello").expect("short msg"); + println!("AppMessage (PQ): {} bytes", short_msg.len()); + + // PQ KeyPackages are larger due to ML-KEM-768 public key (1184 bytes) + assert!(kp_bytes.len() > 1000, "Hybrid KeyPackage should be >1KB due to ML-KEM"); + assert!(kp_bytes.len() < 3000, "Hybrid KeyPackage should be <3KB"); + } } diff --git a/crates/quicprochat-p2p/src/envelope.rs b/crates/quicprochat-p2p/src/envelope.rs index 89bafb7..b14be34 100644 --- a/crates/quicprochat-p2p/src/envelope.rs +++ b/crates/quicprochat-p2p/src/envelope.rs @@ -375,4 +375,63 @@ mod tests { let result = MeshEnvelope::from_wire(&garbage); assert!(result.is_err(), "garbage input must return Err, not panic"); } + + /// Measure MeshEnvelope overhead for various payload sizes. + /// This informs constrained link feasibility planning. + #[test] + fn measure_mesh_envelope_overhead() { + let id = test_identity(); + let recipient = [0xAAu8; 32]; + + println!("=== MeshEnvelope Wire Overhead (CBOR) ==="); + + // Empty payload + let env_empty = MeshEnvelope::new(&id, &recipient, vec![], 3600, 5); + let wire_empty = env_empty.to_wire(); + println!("Payload 0B: wire {} bytes (overhead: {} bytes)", wire_empty.len(), wire_empty.len()); + let base_overhead = wire_empty.len(); + + // 1-byte payload + let env_1 = MeshEnvelope::new(&id, &recipient, vec![0x42], 3600, 5); + let wire_1 = env_1.to_wire(); + println!("Payload 1B: wire {} bytes (overhead: {} bytes)", wire_1.len(), wire_1.len() - 1); + + // 10-byte payload ("hello mesh") + let env_10 = MeshEnvelope::new(&id, &recipient, b"hello mesh".to_vec(), 3600, 5); + let wire_10 = env_10.to_wire(); + println!("Payload 10B: wire {} bytes (overhead: {} bytes)", wire_10.len(), wire_10.len() - 10); + + // 50-byte payload + let env_50 = MeshEnvelope::new(&id, &recipient, vec![0x42; 50], 3600, 5); + let wire_50 = env_50.to_wire(); + println!("Payload 50B: wire {} bytes (overhead: {} bytes)", wire_50.len(), wire_50.len() - 50); + + // 100-byte payload (typical short message) + let env_100 = MeshEnvelope::new(&id, &recipient, vec![0x42; 100], 3600, 5); + let wire_100 = env_100.to_wire(); + println!("Payload 100B: wire {} bytes (overhead: {} bytes)", wire_100.len(), wire_100.len() - 100); + + // Broadcast (empty recipient) - saves 32 bytes + let env_bc = MeshEnvelope::new(&id, &[], b"broadcast".to_vec(), 3600, 5); + let wire_bc = env_bc.to_wire(); + println!("Broadcast 9B: wire {} bytes (no recipient)", wire_bc.len()); + + println!("\n=== LoRa Feasibility (SF12/BW125, MTU=51 bytes) ==="); + println!("Empty envelope: {} fragments", (wire_empty.len() + 50) / 51); + println!("10B payload: {} fragments", (wire_10.len() + 50) / 51); + println!("100B payload: {} fragments", (wire_100.len() + 50) / 51); + + // Baseline overhead is fixed fields: + // - id: 32 bytes + // - sender_key: 32 bytes + // - recipient_key: 32 bytes (or 0 for broadcast) + // - signature: 64 bytes + // - ttl_secs: 4 bytes + // - hop_count: 1 byte + // - max_hops: 1 byte + // - timestamp: 8 bytes + // Total fixed: ~174 bytes raw, CBOR adds ~5-10% overhead + assert!(base_overhead < 200, "Base overhead should be under 200 bytes"); + assert!(base_overhead > 150, "Base overhead should be over 150 bytes (sanity check)"); + } }