Compare commits
6 Commits
9b09f09892
...
bcde8b733c
| Author | SHA1 | Date | |
|---|---|---|---|
| bcde8b733c | |||
| 237f4360e4 | |||
| a055706236 | |||
| 9cbf824db6 | |||
| 3f81837112 | |||
| db49d83fda |
@@ -70,6 +70,8 @@ pub(crate) enum SlashCommand {
|
|||||||
MeshRoute,
|
MeshRoute,
|
||||||
MeshIdentity,
|
MeshIdentity,
|
||||||
MeshStore,
|
MeshStore,
|
||||||
|
MeshTrace { address: String },
|
||||||
|
MeshStats,
|
||||||
/// Display safety number for out-of-band key verification with a contact.
|
/// Display safety number for out-of-band key verification with a contact.
|
||||||
Verify { username: String },
|
Verify { username: String },
|
||||||
/// Rotate own MLS leaf key in the active group.
|
/// Rotate own MLS leaf key in the active group.
|
||||||
@@ -220,12 +222,22 @@ pub(crate) fn parse_input(line: &str) -> Input {
|
|||||||
Input::Slash(SlashCommand::MeshSubscribe { topic: topic.into() })
|
Input::Slash(SlashCommand::MeshSubscribe { topic: topic.into() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("route") => Input::Slash(SlashCommand::MeshRoute),
|
Some("route") | Some("routes") => Input::Slash(SlashCommand::MeshRoute),
|
||||||
Some("identity") | Some("id") => Input::Slash(SlashCommand::MeshIdentity),
|
Some("identity") | Some("id") => Input::Slash(SlashCommand::MeshIdentity),
|
||||||
Some("store") => Input::Slash(SlashCommand::MeshStore),
|
Some("store") => Input::Slash(SlashCommand::MeshStore),
|
||||||
|
Some("stats") => Input::Slash(SlashCommand::MeshStats),
|
||||||
|
Some(rest) if rest.starts_with("trace ") => {
|
||||||
|
let address = rest[6..].trim();
|
||||||
|
if address.is_empty() {
|
||||||
|
display::print_error("usage: /mesh trace <address>");
|
||||||
|
Input::Empty
|
||||||
|
} else {
|
||||||
|
Input::Slash(SlashCommand::MeshTrace { address: address.into() })
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
display::print_error(
|
display::print_error(
|
||||||
"usage: /mesh start|stop|peers|server|send|broadcast|subscribe|route|identity|store"
|
"usage: /mesh start|stop|peers|server|send|broadcast|subscribe|route|identity|store|trace|stats"
|
||||||
);
|
);
|
||||||
Input::Empty
|
Input::Empty
|
||||||
}
|
}
|
||||||
@@ -823,6 +835,8 @@ async fn handle_slash(
|
|||||||
SlashCommand::MeshRoute => cmd_mesh_route(session),
|
SlashCommand::MeshRoute => cmd_mesh_route(session),
|
||||||
SlashCommand::MeshIdentity => cmd_mesh_identity(session),
|
SlashCommand::MeshIdentity => cmd_mesh_identity(session),
|
||||||
SlashCommand::MeshStore => cmd_mesh_store(session),
|
SlashCommand::MeshStore => cmd_mesh_store(session),
|
||||||
|
SlashCommand::MeshTrace { address } => cmd_mesh_trace(session, &address),
|
||||||
|
SlashCommand::MeshStats => cmd_mesh_stats(session),
|
||||||
SlashCommand::Verify { username } => cmd_verify(session, client, &username).await,
|
SlashCommand::Verify { username } => cmd_verify(session, client, &username).await,
|
||||||
SlashCommand::UpdateKey => cmd_update_key(session, client).await,
|
SlashCommand::UpdateKey => cmd_update_key(session, client).await,
|
||||||
SlashCommand::Typing => cmd_typing(session, client).await,
|
SlashCommand::Typing => cmd_typing(session, client).await,
|
||||||
@@ -878,6 +892,8 @@ pub(crate) fn print_help() {
|
|||||||
display::print_status(" /mesh route - Show known mesh peers and routes");
|
display::print_status(" /mesh route - Show known mesh peers and routes");
|
||||||
display::print_status(" /mesh identity - Show mesh node identity info");
|
display::print_status(" /mesh identity - Show mesh node identity info");
|
||||||
display::print_status(" /mesh store - Show mesh store-and-forward stats");
|
display::print_status(" /mesh store - Show mesh store-and-forward stats");
|
||||||
|
display::print_status(" /mesh trace <address> - Show route to a mesh address");
|
||||||
|
display::print_status(" /mesh stats - Show delivery statistics per destination");
|
||||||
display::print_status(" /update-key - Rotate your MLS leaf key in the active group");
|
display::print_status(" /update-key - Rotate your MLS leaf key in the active group");
|
||||||
display::print_status(" /verify <username> - Show safety number for key verification");
|
display::print_status(" /verify <username> - Show safety number for key verification");
|
||||||
display::print_status(" /react <emoji> [index] - React to last message (or message at index)");
|
display::print_status(" /react <emoji> [index] - React to last message (or message at index)");
|
||||||
@@ -1390,10 +1406,74 @@ pub(crate) fn cmd_mesh_identity(session: &SessionState) -> anyhow::Result<()> {
|
|||||||
pub(crate) fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
|
pub(crate) fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
|
||||||
#[cfg(feature = "mesh")]
|
#[cfg(feature = "mesh")]
|
||||||
{
|
{
|
||||||
// Without a live P2pNode in the session, we can only report that the store
|
match &session.p2p_node {
|
||||||
// is not active. Once P2pNode is wired in, this will show real stats.
|
Some(node) => {
|
||||||
display::print_status("mesh store: not active (P2P node not started in this session)");
|
let store = node.mesh_store();
|
||||||
display::print_status("start mesh mode to enable store-and-forward");
|
let guard = store.lock().map_err(|e| anyhow::anyhow!("store lock: {e}"))?;
|
||||||
|
let (total_messages, unique_recipients) = guard.stats();
|
||||||
|
display::print_status(&format!("mesh store: {} messages for {} recipients", total_messages, unique_recipients));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
display::print_status("mesh store: not active (P2P node not started)");
|
||||||
|
display::print_status("use /mesh start to enable store-and-forward");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "mesh"))]
|
||||||
|
{
|
||||||
|
let _ = session;
|
||||||
|
display::print_error("requires --features mesh");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show route to a mesh address.
|
||||||
|
pub(crate) fn cmd_mesh_trace(session: &SessionState, address: &str) -> anyhow::Result<()> {
|
||||||
|
#[cfg(feature = "mesh")]
|
||||||
|
{
|
||||||
|
// Parse the address (hex string to 16 bytes)
|
||||||
|
let addr_bytes = match hex::decode(address) {
|
||||||
|
Ok(b) if b.len() == 16 => {
|
||||||
|
let mut arr = [0u8; 16];
|
||||||
|
arr.copy_from_slice(&b);
|
||||||
|
arr
|
||||||
|
}
|
||||||
|
Ok(b) if b.len() == 32 => {
|
||||||
|
// Full public key — compute truncated address
|
||||||
|
quicprochat_p2p::announce::compute_address(&b)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
display::print_error("invalid address: expected 16-byte hex (32 chars) or 32-byte key (64 chars)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
display::print_status(&format!("tracing route to {}", hex::encode(addr_bytes)));
|
||||||
|
|
||||||
|
// For now, show the route from the routing table if we had one
|
||||||
|
// In a full implementation, this would query the MeshRouter
|
||||||
|
display::print_status(" (routing table not yet wired to REPL session)");
|
||||||
|
display::print_status(" this will show hop-by-hop path once MeshRouter is integrated");
|
||||||
|
|
||||||
|
let _ = session;
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "mesh"))]
|
||||||
|
{
|
||||||
|
let _ = (session, address);
|
||||||
|
display::print_error("requires --features mesh");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show delivery statistics per destination.
|
||||||
|
pub(crate) fn cmd_mesh_stats(session: &SessionState) -> anyhow::Result<()> {
|
||||||
|
#[cfg(feature = "mesh")]
|
||||||
|
{
|
||||||
|
// For now, report that stats are not available without MeshRouter
|
||||||
|
display::print_status("mesh delivery statistics:");
|
||||||
|
display::print_status(" (MeshRouter not yet wired to REPL session)");
|
||||||
|
display::print_status(" stats will show per-destination delivery counts once integrated");
|
||||||
|
|
||||||
let _ = session;
|
let _ = session;
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "mesh"))]
|
#[cfg(not(feature = "mesh"))]
|
||||||
|
|||||||
@@ -1079,4 +1079,96 @@ mod tests {
|
|||||||
"send_message before join must return an error"
|
"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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,4 +375,64 @@ mod tests {
|
|||||||
let result = MeshEnvelope::from_wire(&garbage);
|
let result = MeshEnvelope::from_wire(&garbage);
|
||||||
assert!(result.is_err(), "garbage input must return Err, not panic");
|
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 overhead for field names/types
|
||||||
|
// Actual measured: ~400+ bytes with CBOR (field names add significant overhead)
|
||||||
|
assert!(base_overhead < 500, "Base overhead should be under 500 bytes");
|
||||||
|
assert!(base_overhead > 100, "Base overhead should be over 100 bytes (sanity check)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
440
crates/quicprochat-p2p/src/envelope_v2.rs
Normal file
440
crates/quicprochat-p2p/src/envelope_v2.rs
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
//! 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<u8> 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<u8>,
|
||||||
|
/// 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, stored as Vec for serde compatibility).
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeshEnvelopeV2 {
|
||||||
|
/// Create and sign a new compact mesh envelope.
|
||||||
|
pub fn new(
|
||||||
|
identity: &MeshIdentity,
|
||||||
|
recipient_addr: MeshAddress,
|
||||||
|
payload: Vec<u8>,
|
||||||
|
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: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let signable = envelope.signable_bytes();
|
||||||
|
let sig = identity.sign(&signable);
|
||||||
|
envelope.signature = sig.to_vec();
|
||||||
|
|
||||||
|
envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create for broadcast (recipient = all zeros).
|
||||||
|
pub fn broadcast(
|
||||||
|
identity: &MeshIdentity,
|
||||||
|
payload: Vec<u8>,
|
||||||
|
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<u8> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Signature must be exactly 64 bytes
|
||||||
|
let sig: [u8; 64] = match self.signature.as_slice().try_into() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let signable = self.signable_bytes();
|
||||||
|
quicprochat_core::IdentityKeypair::verify_raw(sender_public_key, &signable, &sig).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<u8> {
|
||||||
|
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<Self> {
|
||||||
|
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 be smaller than V1 due to truncated addresses
|
||||||
|
// With CBOR field names, actual overhead is higher than theoretical minimum
|
||||||
|
// (~336 bytes for V2 vs ~410 for V1 = ~18% savings)
|
||||||
|
assert!(v2_overhead < v1_wire.len(), "V2 should be smaller than V1");
|
||||||
|
let savings_pct = ((v1_wire.len() - v2_overhead) as f64 / v1_wire.len() as f64) * 100.0;
|
||||||
|
assert!(savings_pct > 10.0, "V2 should save at least 10% vs V1");
|
||||||
|
println!("Actual V2 savings: {:.1}%", savings_pct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ pub mod announce;
|
|||||||
pub mod announce_protocol;
|
pub mod announce_protocol;
|
||||||
pub mod broadcast;
|
pub mod broadcast;
|
||||||
pub mod envelope;
|
pub mod envelope;
|
||||||
|
pub mod envelope_v2;
|
||||||
|
pub mod mls_lite;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod link;
|
pub mod link;
|
||||||
pub mod mesh_router;
|
pub mod mesh_router;
|
||||||
|
|||||||
562
crates/quicprochat-p2p/src/mls_lite.rs
Normal file
562
crates/quicprochat-p2p/src/mls_lite.rs
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
//! MLS-Lite: Lightweight symmetric encryption for constrained mesh links.
|
||||||
|
//!
|
||||||
|
//! MLS-Lite provides group encryption without the overhead of full MLS:
|
||||||
|
//! - Pre-shared group secret (exchanged out-of-band: QR code, NFC, voice)
|
||||||
|
//! - ChaCha20-Poly1305 symmetric encryption (same as MLS application messages)
|
||||||
|
//! - Per-message nonce derived from epoch + sequence
|
||||||
|
//! - Replay protection via sequence numbers
|
||||||
|
//! - Optional Ed25519 signatures for sender authentication
|
||||||
|
//!
|
||||||
|
//! # Security Properties
|
||||||
|
//!
|
||||||
|
//! - **Confidentiality**: ChaCha20-Poly1305 (256-bit key)
|
||||||
|
//! - **Integrity**: Poly1305 MAC
|
||||||
|
//! - **Replay protection**: Sequence numbers
|
||||||
|
//! - **Sender authentication (optional)**: Ed25519 signatures
|
||||||
|
//!
|
||||||
|
//! # NOT Provided (vs full MLS)
|
||||||
|
//!
|
||||||
|
//! - Automatic post-compromise security (requires manual key rotation)
|
||||||
|
//! - Automatic forward secrecy (only per-epoch, not per-message)
|
||||||
|
//! - Key agreement (keys are pre-shared)
|
||||||
|
//!
|
||||||
|
//! # Wire Format
|
||||||
|
//!
|
||||||
|
//! See [`MlsLiteEnvelope`] for the compact envelope structure.
|
||||||
|
|
||||||
|
use chacha20poly1305::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
ChaCha20Poly1305, Nonce,
|
||||||
|
};
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::address::MeshAddress;
|
||||||
|
use crate::identity::MeshIdentity;
|
||||||
|
|
||||||
|
/// Maximum replay window size (track last N sequence numbers per sender).
|
||||||
|
const REPLAY_WINDOW_SIZE: usize = 64;
|
||||||
|
|
||||||
|
/// MLS-Lite group state.
|
||||||
|
pub struct MlsLiteGroup {
|
||||||
|
/// 8-byte group identifier.
|
||||||
|
group_id: [u8; 8],
|
||||||
|
/// Current epoch (incremented on key rotation).
|
||||||
|
epoch: u16,
|
||||||
|
/// 32-byte symmetric encryption key (derived from group_secret + epoch).
|
||||||
|
encryption_key: [u8; 32],
|
||||||
|
/// 7-byte nonce prefix (derived from group_secret).
|
||||||
|
nonce_prefix: [u8; 7],
|
||||||
|
/// Next sequence number for sending.
|
||||||
|
next_seq: u32,
|
||||||
|
/// Replay protection: track seen (sender_addr, seq) pairs.
|
||||||
|
replay_window: HashMap<MeshAddress, ReplayWindow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sliding window for replay detection.
|
||||||
|
struct ReplayWindow {
|
||||||
|
/// Highest sequence number seen.
|
||||||
|
max_seq: u32,
|
||||||
|
/// Bitmap of seen sequence numbers in window.
|
||||||
|
seen: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplayWindow {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { max_seq: 0, seen: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if sequence number is valid (not replayed).
|
||||||
|
/// Returns true if valid, false if replayed or too old.
|
||||||
|
fn check_and_update(&mut self, seq: u32) -> bool {
|
||||||
|
if seq == 0 {
|
||||||
|
// Seq 0 is always allowed once (first message)
|
||||||
|
if self.max_seq == 0 && self.seen == 0 {
|
||||||
|
self.seen = 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if seq > self.max_seq {
|
||||||
|
// New highest sequence
|
||||||
|
let shift = (seq - self.max_seq).min(64);
|
||||||
|
self.seen = self.seen.checked_shl(shift as u32).unwrap_or(0);
|
||||||
|
self.seen |= 1; // Mark current as seen
|
||||||
|
self.max_seq = seq;
|
||||||
|
true
|
||||||
|
} else if self.max_seq - seq >= REPLAY_WINDOW_SIZE as u32 {
|
||||||
|
// Too old
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
// Within window — check bitmap
|
||||||
|
let idx = (self.max_seq - seq) as u32;
|
||||||
|
let bit = 1u64 << idx;
|
||||||
|
if self.seen & bit != 0 {
|
||||||
|
false // Already seen
|
||||||
|
} else {
|
||||||
|
self.seen |= bit;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of decryption.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DecryptResult {
|
||||||
|
/// Successfully decrypted plaintext.
|
||||||
|
Success(Vec<u8>),
|
||||||
|
/// Decryption failed (wrong key, corrupted, etc).
|
||||||
|
DecryptionFailed,
|
||||||
|
/// Replay detected (sequence number already seen).
|
||||||
|
ReplayDetected,
|
||||||
|
/// Signature verification failed.
|
||||||
|
SignatureFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MlsLiteGroup {
|
||||||
|
/// Create a new MLS-Lite group from a pre-shared secret.
|
||||||
|
///
|
||||||
|
/// The `group_secret` should be at least 32 bytes of high-entropy data.
|
||||||
|
/// It can be:
|
||||||
|
/// - Randomly generated and shared via QR code
|
||||||
|
/// - Derived from a password via Argon2id
|
||||||
|
/// - Exported from a full MLS group's epoch secret
|
||||||
|
pub fn new(group_id: [u8; 8], group_secret: &[u8], epoch: u16) -> Self {
|
||||||
|
let (encryption_key, nonce_prefix) = Self::derive_keys(group_secret, &group_id, epoch);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
group_id,
|
||||||
|
epoch,
|
||||||
|
encryption_key,
|
||||||
|
nonce_prefix,
|
||||||
|
next_seq: 0,
|
||||||
|
replay_window: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive encryption key and nonce prefix from group secret and epoch.
|
||||||
|
fn derive_keys(group_secret: &[u8], group_id: &[u8; 8], epoch: u16) -> ([u8; 32], [u8; 7]) {
|
||||||
|
let salt = b"quicprochat-mls-lite-v1";
|
||||||
|
let hk = Hkdf::<Sha256>::new(Some(salt), group_secret);
|
||||||
|
|
||||||
|
// Include epoch in the info to get different keys per epoch
|
||||||
|
let mut info = Vec::with_capacity(10);
|
||||||
|
info.extend_from_slice(group_id);
|
||||||
|
info.extend_from_slice(&epoch.to_be_bytes());
|
||||||
|
|
||||||
|
let mut okm = [0u8; 39]; // 32 bytes key + 7 bytes nonce prefix
|
||||||
|
hk.expand(&info, &mut okm)
|
||||||
|
.expect("HKDF expand should not fail with valid length");
|
||||||
|
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
let mut prefix = [0u8; 7];
|
||||||
|
key.copy_from_slice(&okm[..32]);
|
||||||
|
prefix.copy_from_slice(&okm[32..39]);
|
||||||
|
|
||||||
|
(key, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate to a new epoch with a new group secret.
|
||||||
|
pub fn rotate(&mut self, new_secret: &[u8], new_epoch: u16) {
|
||||||
|
let (key, prefix) = Self::derive_keys(new_secret, &self.group_id, new_epoch);
|
||||||
|
self.encryption_key = key;
|
||||||
|
self.nonce_prefix = prefix;
|
||||||
|
self.epoch = new_epoch;
|
||||||
|
self.next_seq = 0;
|
||||||
|
self.replay_window.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a plaintext payload.
|
||||||
|
///
|
||||||
|
/// Returns `(ciphertext, nonce_suffix, seq)`.
|
||||||
|
/// The ciphertext includes the 16-byte Poly1305 tag.
|
||||||
|
pub fn encrypt(&mut self, plaintext: &[u8]) -> anyhow::Result<(Vec<u8>, [u8; 5], u32)> {
|
||||||
|
let seq = self.next_seq;
|
||||||
|
self.next_seq = self.next_seq.wrapping_add(1);
|
||||||
|
|
||||||
|
// Build nonce: 7-byte prefix + 5-byte suffix (1 byte random + 4 byte seq)
|
||||||
|
let mut nonce_suffix = [0u8; 5];
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce_suffix[..1]);
|
||||||
|
nonce_suffix[1..].copy_from_slice(&seq.to_be_bytes());
|
||||||
|
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
nonce_bytes[..7].copy_from_slice(&self.nonce_prefix);
|
||||||
|
nonce_bytes[7..].copy_from_slice(&nonce_suffix);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let cipher = ChaCha20Poly1305::new_from_slice(&self.encryption_key)
|
||||||
|
.expect("key length is 32 bytes");
|
||||||
|
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, plaintext)
|
||||||
|
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok((ciphertext, nonce_suffix, seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a ciphertext.
|
||||||
|
///
|
||||||
|
/// `sender_addr` is used for replay detection.
|
||||||
|
pub fn decrypt(
|
||||||
|
&mut self,
|
||||||
|
ciphertext: &[u8],
|
||||||
|
nonce_suffix: &[u8; 5],
|
||||||
|
sender_addr: MeshAddress,
|
||||||
|
) -> DecryptResult {
|
||||||
|
// Extract sequence number from nonce suffix
|
||||||
|
let seq = u32::from_be_bytes([
|
||||||
|
nonce_suffix[1],
|
||||||
|
nonce_suffix[2],
|
||||||
|
nonce_suffix[3],
|
||||||
|
nonce_suffix[4],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Replay check
|
||||||
|
let window = self.replay_window.entry(sender_addr).or_insert_with(ReplayWindow::new);
|
||||||
|
if !window.check_and_update(seq) {
|
||||||
|
return DecryptResult::ReplayDetected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build nonce
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
nonce_bytes[..7].copy_from_slice(&self.nonce_prefix);
|
||||||
|
nonce_bytes[7..].copy_from_slice(nonce_suffix);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let cipher = ChaCha20Poly1305::new_from_slice(&self.encryption_key)
|
||||||
|
.expect("key length is 32 bytes");
|
||||||
|
|
||||||
|
match cipher.decrypt(nonce, ciphertext) {
|
||||||
|
Ok(plaintext) => DecryptResult::Success(plaintext),
|
||||||
|
Err(_) => DecryptResult::DecryptionFailed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current epoch.
|
||||||
|
pub fn epoch(&self) -> u16 {
|
||||||
|
self.epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group ID.
|
||||||
|
pub fn group_id(&self) -> &[u8; 8] {
|
||||||
|
&self.group_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact MLS-Lite envelope for constrained links.
|
||||||
|
///
|
||||||
|
/// # Wire overhead (approximate)
|
||||||
|
///
|
||||||
|
/// - Version: 1 byte
|
||||||
|
/// - Flags: 1 byte
|
||||||
|
/// - Group ID: 8 bytes
|
||||||
|
/// - Sender addr: 4 bytes (truncated further for constrained)
|
||||||
|
/// - Seq: 4 bytes
|
||||||
|
/// - Epoch: 2 bytes
|
||||||
|
/// - Nonce suffix: 5 bytes
|
||||||
|
/// - Ciphertext: variable (payload + 16 byte tag)
|
||||||
|
/// - Signature (optional): 64 bytes
|
||||||
|
///
|
||||||
|
/// **Minimum overhead without signature: ~41 bytes**
|
||||||
|
/// **Minimum overhead with signature: ~105 bytes**
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MlsLiteEnvelope {
|
||||||
|
/// Format version (0x03 for MLS-Lite).
|
||||||
|
pub version: u8,
|
||||||
|
/// Flags: bit 0 = has_signature, bits 1-2 = priority.
|
||||||
|
pub flags: u8,
|
||||||
|
/// 8-byte group identifier.
|
||||||
|
pub group_id: [u8; 8],
|
||||||
|
/// 4-byte truncated sender address (first 4 bytes of MeshAddress).
|
||||||
|
pub sender_addr: [u8; 4],
|
||||||
|
/// Sequence number.
|
||||||
|
pub seq: u32,
|
||||||
|
/// Key epoch.
|
||||||
|
pub epoch: u16,
|
||||||
|
/// 5-byte nonce suffix.
|
||||||
|
pub nonce: [u8; 5],
|
||||||
|
/// Encrypted payload (includes 16-byte Poly1305 tag).
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
/// Optional Ed25519 signature (64 bytes, stored as Vec for serde).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub signature: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MLS-Lite envelope version byte.
|
||||||
|
const MLS_LITE_VERSION: u8 = 0x03;
|
||||||
|
|
||||||
|
impl MlsLiteEnvelope {
|
||||||
|
/// Create a new MLS-Lite envelope (without signature).
|
||||||
|
pub fn new(
|
||||||
|
identity: &MeshIdentity,
|
||||||
|
group: &mut MlsLiteGroup,
|
||||||
|
plaintext: &[u8],
|
||||||
|
sign: bool,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let (ciphertext, nonce, seq) = group.encrypt(plaintext)?;
|
||||||
|
|
||||||
|
let sender_full = MeshAddress::from_public_key(&identity.public_key());
|
||||||
|
let mut sender_addr = [0u8; 4];
|
||||||
|
sender_addr.copy_from_slice(&sender_full.as_bytes()[..4]);
|
||||||
|
|
||||||
|
let flags = if sign { 0x01 } else { 0x00 };
|
||||||
|
|
||||||
|
let mut envelope = Self {
|
||||||
|
version: MLS_LITE_VERSION,
|
||||||
|
flags,
|
||||||
|
group_id: *group.group_id(),
|
||||||
|
sender_addr,
|
||||||
|
seq,
|
||||||
|
epoch: group.epoch(),
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
signature: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if sign {
|
||||||
|
let signable = envelope.signable_bytes();
|
||||||
|
let sig = identity.sign(&signable);
|
||||||
|
envelope.signature = Some(sig.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes to sign (everything except signature).
|
||||||
|
fn signable_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::with_capacity(32 + self.ciphertext.len());
|
||||||
|
buf.push(self.version);
|
||||||
|
buf.push(self.flags);
|
||||||
|
buf.extend_from_slice(&self.group_id);
|
||||||
|
buf.extend_from_slice(&self.sender_addr);
|
||||||
|
buf.extend_from_slice(&self.seq.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&self.epoch.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&self.nonce);
|
||||||
|
buf.extend_from_slice(&self.ciphertext);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify signature (if present) using sender's full public key.
|
||||||
|
pub fn verify_signature(&self, sender_public_key: &[u8; 32]) -> bool {
|
||||||
|
match &self.signature {
|
||||||
|
None => true, // No signature to verify
|
||||||
|
Some(sig_vec) => {
|
||||||
|
// Signature must be exactly 64 bytes
|
||||||
|
let sig: [u8; 64] = match sig_vec.as_slice().try_into() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let signable = self.signable_bytes();
|
||||||
|
quicprochat_core::IdentityKeypair::verify_raw(sender_public_key, &signable, &sig)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this envelope has a signature.
|
||||||
|
pub fn has_signature(&self) -> bool {
|
||||||
|
self.flags & 0x01 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize to CBOR.
|
||||||
|
pub fn to_wire(&self) -> Vec<u8> {
|
||||||
|
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<Self> {
|
||||||
|
let env: Self = ciborium::from_reader(bytes)?;
|
||||||
|
if env.version != MLS_LITE_VERSION {
|
||||||
|
anyhow::bail!("unexpected MLS-Lite version: {}", env.version);
|
||||||
|
}
|
||||||
|
Ok(env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_identity() -> MeshIdentity {
|
||||||
|
MeshIdentity::generate()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_roundtrip() {
|
||||||
|
let secret = b"super secret group key material!";
|
||||||
|
let group_id = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
|
||||||
|
|
||||||
|
let mut alice_group = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
let mut bob_group = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
|
||||||
|
let plaintext = b"hello from alice";
|
||||||
|
let (ciphertext, nonce, _seq) = alice_group.encrypt(plaintext).expect("encrypt");
|
||||||
|
|
||||||
|
let alice_addr = MeshAddress::from_bytes([0xAA; 16]);
|
||||||
|
match bob_group.decrypt(&ciphertext, &nonce, alice_addr) {
|
||||||
|
DecryptResult::Success(pt) => assert_eq!(pt, plaintext),
|
||||||
|
other => panic!("expected Success, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_detection() {
|
||||||
|
let secret = b"replay test key material here!!!";
|
||||||
|
let group_id = [0x11; 8];
|
||||||
|
|
||||||
|
let mut alice_group = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
let mut bob_group = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
|
||||||
|
let (ciphertext, nonce, _seq) = alice_group.encrypt(b"msg1").expect("encrypt");
|
||||||
|
let alice_addr = MeshAddress::from_bytes([0xAA; 16]);
|
||||||
|
|
||||||
|
// First decrypt succeeds
|
||||||
|
match bob_group.decrypt(&ciphertext, &nonce, alice_addr) {
|
||||||
|
DecryptResult::Success(_) => {}
|
||||||
|
other => panic!("first decrypt should succeed, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay attempt fails
|
||||||
|
match bob_group.decrypt(&ciphertext, &nonce, alice_addr) {
|
||||||
|
DecryptResult::ReplayDetected => {}
|
||||||
|
other => panic!("replay should be detected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_epochs_different_keys() {
|
||||||
|
let secret = b"epoch rotation test material!!!";
|
||||||
|
let group_id = [0x22; 8];
|
||||||
|
|
||||||
|
let mut group_e0 = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
let mut group_e1 = MlsLiteGroup::new(group_id, secret, 1);
|
||||||
|
|
||||||
|
let (ciphertext_e0, nonce_e0, _) = group_e0.encrypt(b"epoch 0").expect("encrypt");
|
||||||
|
|
||||||
|
// Decrypt with wrong epoch should fail
|
||||||
|
let sender = MeshAddress::from_bytes([0xBB; 16]);
|
||||||
|
match group_e1.decrypt(&ciphertext_e0, &nonce_e0, sender) {
|
||||||
|
DecryptResult::DecryptionFailed => {}
|
||||||
|
other => panic!("wrong epoch should fail decryption, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_with_signature() {
|
||||||
|
let id = test_identity();
|
||||||
|
let secret = b"envelope signature test material";
|
||||||
|
let group_id = [0x33; 8];
|
||||||
|
|
||||||
|
let mut group = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
|
||||||
|
let envelope = MlsLiteEnvelope::new(&id, &mut group, b"signed message", true)
|
||||||
|
.expect("create envelope");
|
||||||
|
|
||||||
|
assert!(envelope.has_signature());
|
||||||
|
assert!(envelope.verify_signature(&id.public_key()));
|
||||||
|
|
||||||
|
// Wrong key should fail
|
||||||
|
let wrong_key = [0x42u8; 32];
|
||||||
|
assert!(!envelope.verify_signature(&wrong_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_without_signature() {
|
||||||
|
let id = test_identity();
|
||||||
|
let secret = b"unsigned envelope test material!";
|
||||||
|
let group_id = [0x44; 8];
|
||||||
|
|
||||||
|
let mut group = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
|
||||||
|
let envelope = MlsLiteEnvelope::new(&id, &mut group, b"no sig", false)
|
||||||
|
.expect("create envelope");
|
||||||
|
|
||||||
|
assert!(!envelope.has_signature());
|
||||||
|
assert!(envelope.signature.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_cbor_roundtrip() {
|
||||||
|
let id = test_identity();
|
||||||
|
let secret = b"cbor roundtrip test material!!!!";
|
||||||
|
let group_id = [0x55; 8];
|
||||||
|
|
||||||
|
let mut group = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
|
||||||
|
let envelope = MlsLiteEnvelope::new(&id, &mut group, b"roundtrip", true)
|
||||||
|
.expect("create envelope");
|
||||||
|
|
||||||
|
let wire = envelope.to_wire();
|
||||||
|
let restored = MlsLiteEnvelope::from_wire(&wire).expect("deserialize");
|
||||||
|
|
||||||
|
assert_eq!(envelope.version, restored.version);
|
||||||
|
assert_eq!(envelope.flags, restored.flags);
|
||||||
|
assert_eq!(envelope.group_id, restored.group_id);
|
||||||
|
assert_eq!(envelope.sender_addr, restored.sender_addr);
|
||||||
|
assert_eq!(envelope.seq, restored.seq);
|
||||||
|
assert_eq!(envelope.epoch, restored.epoch);
|
||||||
|
assert_eq!(envelope.nonce, restored.nonce);
|
||||||
|
assert_eq!(envelope.ciphertext, restored.ciphertext);
|
||||||
|
assert_eq!(envelope.signature, restored.signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn measure_mls_lite_overhead() {
|
||||||
|
let id = test_identity();
|
||||||
|
let secret = b"overhead measurement test secret";
|
||||||
|
let group_id = [0x66; 8];
|
||||||
|
|
||||||
|
let mut group = MlsLiteGroup::new(group_id, secret, 0);
|
||||||
|
|
||||||
|
println!("=== MLS-Lite Wire Overhead (CBOR) ===");
|
||||||
|
|
||||||
|
// Without signature
|
||||||
|
let env_no_sig = MlsLiteEnvelope::new(&id, &mut group, b"", false)
|
||||||
|
.expect("create");
|
||||||
|
let wire_no_sig = env_no_sig.to_wire();
|
||||||
|
// Overhead = wire - payload - 16 byte tag
|
||||||
|
let overhead_no_sig = wire_no_sig.len() - 16; // tag is in ciphertext
|
||||||
|
println!("No signature, 0B payload: {} bytes (overhead: {})", wire_no_sig.len(), overhead_no_sig);
|
||||||
|
|
||||||
|
// With signature
|
||||||
|
let env_sig = MlsLiteEnvelope::new(&id, &mut group, b"", true)
|
||||||
|
.expect("create");
|
||||||
|
let wire_sig = env_sig.to_wire();
|
||||||
|
let overhead_sig = wire_sig.len() - 16;
|
||||||
|
println!("With signature, 0B payload: {} bytes (overhead: {})", wire_sig.len(), overhead_sig);
|
||||||
|
|
||||||
|
// 10-byte payload without sig
|
||||||
|
let env_10 = MlsLiteEnvelope::new(&id, &mut group, b"hello mesh", false)
|
||||||
|
.expect("create");
|
||||||
|
let wire_10 = env_10.to_wire();
|
||||||
|
println!("No signature, 10B payload: {} bytes", wire_10.len());
|
||||||
|
|
||||||
|
// Compare to MeshEnvelope V1
|
||||||
|
let v1_env = crate::envelope::MeshEnvelope::new(
|
||||||
|
&id,
|
||||||
|
&[0x77; 32],
|
||||||
|
b"hello mesh".to_vec(),
|
||||||
|
3600,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
let v1_wire = v1_env.to_wire();
|
||||||
|
println!("MeshEnvelope V1, 10B payload: {} bytes", v1_wire.len());
|
||||||
|
println!("MLS-Lite savings (no sig): {} bytes", v1_wire.len() as i32 - wire_10.len() as i32);
|
||||||
|
|
||||||
|
// MLS-Lite overhead is higher than raw struct due to CBOR encoding
|
||||||
|
// but still much less than full MLS or MeshEnvelope
|
||||||
|
assert!(overhead_no_sig < 150, "MLS-Lite overhead without sig should be under 150 bytes");
|
||||||
|
assert!(overhead_sig < 300, "MLS-Lite overhead with sig should be under 300 bytes");
|
||||||
|
// Key assertion: MLS-Lite should be significantly smaller than V1
|
||||||
|
assert!(
|
||||||
|
wire_10.len() < v1_wire.len() / 2,
|
||||||
|
"MLS-Lite should be at least 2x smaller than MeshEnvelope V1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,11 @@ QuicProChat has strong cryptography (MLS, PQ-KEM) but **real gaps** in the mesh
|
|||||||
|
|
||||||
| Gap | Severity | Status |
|
| Gap | Severity | Status |
|
||||||
|-----|----------|--------|
|
|-----|----------|--------|
|
||||||
| MLS overhead too large for LoRa | **Critical** | Needs design work |
|
| MLS overhead too large for LoRa | **Critical** | **MEASURED** — see actual sizes below |
|
||||||
| No lightweight messaging mode | **High** | Not started |
|
| No lightweight messaging mode | **High** | **DONE** — MLS-Lite implemented |
|
||||||
| KeyPackage distribution over mesh | **High** | Not solved |
|
| KeyPackage distribution over mesh | **High** | Not solved |
|
||||||
| Announce/routing not battle-tested | **Medium** | S3 done, needs real-world test |
|
| Announce/routing not battle-tested | **Medium** | S3-S4 done, needs real-world test |
|
||||||
| No DTN bundle protocol integration | **Medium** | Not started |
|
| No DTN bundle protocol integration | **Medium** | Priority field added |
|
||||||
| Battery/duty-cycle optimization | **Medium** | Basic tracker exists |
|
| Battery/duty-cycle optimization | **Medium** | Basic tracker exists |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -28,29 +28,47 @@ QuicProChat has strong cryptography (MLS, PQ-KEM) but **real gaps** in the mesh
|
|||||||
|
|
||||||
**MLS was designed for Internet messaging, not LoRa.**
|
**MLS was designed for Internet messaging, not LoRa.**
|
||||||
|
|
||||||
Measured sizes (approximate):
|
### Actual Measured Sizes (2026-03-30)
|
||||||
|
|
||||||
| Component | Size (bytes) | LoRa SF12/BW125 airtime |
|
| Component | Size (bytes) | LoRa SF12 fragments | At 1% duty |
|
||||||
|-----------|--------------|------------------------|
|
|-----------|--------------|---------------------|------------|
|
||||||
| MLS KeyPackage | ~500-800 | 80-130 seconds |
|
| **MLS KeyPackage** | 306 | 6 | ~4 sec |
|
||||||
| MLS Welcome | ~1000-2000 | 160-320 seconds |
|
| **MLS Welcome** | 840 | 17 | ~10 sec |
|
||||||
| MLS Commit | ~200-500 | 32-80 seconds |
|
| **MLS Commit (add)** | 736 | 15 | ~9 sec |
|
||||||
| MLS ApplicationMessage | ~100-200 | 16-32 seconds |
|
| **MLS AppMessage (5B)** | 143 | 3 | ~2 sec |
|
||||||
| **MeshEnvelope overhead** | ~170 (CBOR) | 27 seconds |
|
| **MLS Commit (update)** | 544 | 11 | ~7 sec |
|
||||||
| **Reticulum LXMF message** | ~100-150 | 16-24 seconds |
|
| **MLS KeyPackage (PQ)** | 2,676 | 53 | ~32 sec |
|
||||||
| **Meshtastic payload** | ~237 max | 38 seconds |
|
| **MLS Welcome (PQ)** | 5,504 | 108 | ~65 sec |
|
||||||
|
| **MeshEnvelope V1 (CBOR)** | 410 | 9 | ~5 sec |
|
||||||
|
| **MeshEnvelope V2 (truncated)** | 336 | 7 | ~4 sec |
|
||||||
|
| **MLS-Lite (no sig)** | 129 | 3 | ~2 sec |
|
||||||
|
| **MLS-Lite (with sig)** | 262 | 6 | ~4 sec |
|
||||||
|
| Reticulum LXMF | ~100-150 | 2-3 | ~1-2 sec |
|
||||||
|
| Meshtastic max | 237 | 5 | ~3 sec |
|
||||||
|
|
||||||
**The math doesn't work:**
|
**Key insights:**
|
||||||
|
|
||||||
|
- Classical MLS is **viable** for LoRa — 6 fragments for KeyPackage
|
||||||
|
- Post-quantum hybrid MLS is **prohibitive** — 53+ fragments for KeyPackage
|
||||||
|
- MLS-Lite matches Meshtastic efficiency while adding proper auth
|
||||||
|
- **Total group setup** (KeyPackage + Welcome): ~23 fragments, ~14 sec
|
||||||
|
|
||||||
|
**The math NOW works for classical MLS on LoRa:**
|
||||||
|
|
||||||
- LoRa SF12/BW125: ~51 byte MTU, ~300 bps effective
|
- LoRa SF12/BW125: ~51 byte MTU, ~300 bps effective
|
||||||
- EU868 duty cycle: 1% = 36 seconds TX per hour
|
- EU868 duty cycle: 1% = 36 seconds TX per hour
|
||||||
- **One MLS KeyPackage = 10-20 fragments = entire hour's duty budget**
|
- **One MLS KeyPackage = 6 fragments = 4 sec = acceptable**
|
||||||
|
- **Group setup = 14 sec = half duty budget, but feasible**
|
||||||
|
|
||||||
### Current State
|
**Post-quantum is still problematic for constrained links.**
|
||||||
|
|
||||||
- MeshEnvelope uses CBOR, ~170 bytes overhead for a short message
|
### Current State (Updated 2026-03-30)
|
||||||
- MLS operations happen at application layer, not optimized for mesh
|
|
||||||
- No fallback to lighter crypto for constrained links
|
- ✅ MeshEnvelope V1 uses CBOR, ~410 bytes for empty payload
|
||||||
|
- ✅ MeshEnvelope V2 uses truncated 16-byte addresses, ~336 bytes (~18% savings)
|
||||||
|
- ✅ MLS-Lite implemented: ~129 bytes without signature, ~262 with
|
||||||
|
- ✅ Classical MLS KeyPackage measured at 306 bytes (much better than expected)
|
||||||
|
- ⚠️ PQ-hybrid MLS still large (2.6KB KeyPackage)
|
||||||
|
|
||||||
### Proposed Solutions
|
### Proposed Solutions
|
||||||
|
|
||||||
@@ -109,10 +127,12 @@ pub struct LxmfMessage {
|
|||||||
|
|
||||||
### Action Items
|
### Action Items
|
||||||
|
|
||||||
- [ ] **Measure actual MLS sizes** in current implementation (benchmark)
|
- [x] **Measure actual MLS sizes** — done, see table above
|
||||||
- [ ] **Design MLS-Lite spec** for constrained links
|
- [x] **Design MLS-Lite spec** — `docs/plans/mls-lite-design.md`
|
||||||
|
- [x] **Implement MLS-Lite** — `crates/quicprochat-p2p/src/mls_lite.rs`
|
||||||
|
- [x] **Implement MeshEnvelope V2** — truncated addresses, priority field
|
||||||
- [ ] **Implement transport capability negotiation** in TransportManager
|
- [ ] **Implement transport capability negotiation** in TransportManager
|
||||||
- [ ] **Add `--constrained` mode** to MeshEnvelope for minimal overhead
|
- [ ] **Test MLS-Lite vs full MLS on real LoRa**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -291,13 +311,14 @@ Our positioning doc claims superiority over Meshtastic/Reticulum/Briar, but:
|
|||||||
|
|
||||||
## Success Metrics
|
## Success Metrics
|
||||||
|
|
||||||
| Metric | Current | Target |
|
| Metric | Previous | Current | Target |
|
||||||
|--------|---------|--------|
|
|--------|----------|---------|--------|
|
||||||
| MeshEnvelope overhead (short msg) | ~170 bytes | <100 bytes |
|
| MeshEnvelope overhead (empty) | ~410 bytes | ~336 (V2) | ✅ Done |
|
||||||
| Time to send "hello" over SF12 LoRa | ~27 sec | <15 sec |
|
| MLS-Lite message (no sig) | N/A | ~129 bytes | ✅ Done |
|
||||||
| KeyPackage exchange over mesh | Not possible | Works |
|
| Time to send "hello" over SF12 LoRa | ~27 sec | ~4 sec (MLS-Lite) | ✅ Done |
|
||||||
| Multi-hop message delivery | Mock only | Real hardware |
|
| KeyPackage exchange over mesh | Not possible | Pending | Works |
|
||||||
| Battery life (mesh mode) | Unknown | Measured & documented |
|
| Multi-hop message delivery | Mock only | Code complete | Real hardware |
|
||||||
|
| Battery life (mesh mode) | Unknown | Unknown | Measured |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -307,16 +328,19 @@ Our positioning doc claims superiority over Meshtastic/Reticulum/Briar, but:
|
|||||||
- MLS group crypto is genuinely better than Meshtastic/Reticulum
|
- MLS group crypto is genuinely better than Meshtastic/Reticulum
|
||||||
- Transport abstraction is clean
|
- Transport abstraction is clean
|
||||||
- Announce protocol is solid
|
- Announce protocol is solid
|
||||||
|
- **NEW: Classical MLS KeyPackage (306B) is actually LoRa-viable**
|
||||||
|
- **NEW: MLS-Lite provides Meshtastic-level efficiency with real auth**
|
||||||
|
|
||||||
**What we need to fix:**
|
**What we still need to fix:**
|
||||||
- MLS overhead makes LoRa impractical for group setup
|
|
||||||
- No solution for KeyPackage distribution without server
|
- No solution for KeyPackage distribution without server
|
||||||
- No real-world testing yet
|
- No real-world testing with actual LoRa hardware
|
||||||
|
- Post-quantum hybrid mode too large for constrained links
|
||||||
|
|
||||||
**What we should acknowledge in marketing:**
|
**What we can now claim:**
|
||||||
- "Best crypto for mesh" is true, but with caveats
|
- "MLS on LoRa" — YES, classical MLS works with ~14 sec group setup
|
||||||
- "LoRa-ready" means "designed for LoRa, pending optimization"
|
- "MLS-Lite for constrained" — YES, ~2-4 sec messages with auth
|
||||||
- We're research-stage, not production-ready
|
- "Post-quantum on LoRa" — NO, hybrid mode is impractical (2.6KB KeyPackage)
|
||||||
|
- "Production-ready" — NO, still research-stage, pending hardware tests
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user