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.
This commit is contained in:
432
crates/quicprochat-p2p/src/envelope_v2.rs
Normal file
432
crates/quicprochat-p2p/src/envelope_v2.rs
Normal file
@@ -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<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).
|
||||||
|
pub signature: [u8; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [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<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;
|
||||||
|
}
|
||||||
|
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<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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ 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 identity;
|
pub mod identity;
|
||||||
pub mod link;
|
pub mod link;
|
||||||
pub mod mesh_router;
|
pub mod mesh_router;
|
||||||
|
|||||||
Reference in New Issue
Block a user