Compare commits
3 Commits
5d1688d89f
...
7be7287ba2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7be7287ba2 | |||
| 3c6eebdb00 | |||
| eee1e9f278 |
@@ -17,6 +17,8 @@ pub const CAP_STORE: u16 = 0x0002;
|
||||
pub const CAP_GATEWAY: u16 = 0x0004;
|
||||
/// Capability flag: node is on a low-bandwidth transport only.
|
||||
pub const CAP_CONSTRAINED: u16 = 0x0008;
|
||||
/// Capability flag: node has KeyPackages available for MLS group invites.
|
||||
pub const CAP_MLS_READY: u16 = 0x0010;
|
||||
|
||||
/// A signed mesh node announcement.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -37,6 +39,10 @@ pub struct MeshAnnounce {
|
||||
pub hop_count: u8,
|
||||
/// Maximum propagation hops.
|
||||
pub max_hops: u8,
|
||||
/// Optional hash of current KeyPackage (SHA-256, truncated to 8 bytes).
|
||||
/// Present when CAP_MLS_READY is set. Peers can request the full KeyPackage.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub keypackage_hash: Option<[u8; 8]>,
|
||||
/// Ed25519 signature over all fields except signature and hop_count.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
@@ -51,6 +57,16 @@ pub fn compute_address(identity_key: &[u8]) -> [u8; 16] {
|
||||
addr
|
||||
}
|
||||
|
||||
/// Compute the 8-byte truncated hash of a KeyPackage for announce inclusion.
|
||||
///
|
||||
/// This hash is used to identify which KeyPackage version a node has available.
|
||||
pub fn compute_keypackage_hash(keypackage_bytes: &[u8]) -> [u8; 8] {
|
||||
let hash = Sha256::digest(keypackage_bytes);
|
||||
let mut kp_hash = [0u8; 8];
|
||||
kp_hash.copy_from_slice(&hash[..8]);
|
||||
kp_hash
|
||||
}
|
||||
|
||||
impl MeshAnnounce {
|
||||
/// Create and sign a new mesh announcement.
|
||||
pub fn new(
|
||||
@@ -58,6 +74,17 @@ impl MeshAnnounce {
|
||||
capabilities: u16,
|
||||
reachable_via: Vec<(String, Vec<u8>)>,
|
||||
max_hops: u8,
|
||||
) -> Self {
|
||||
Self::with_keypackage(identity, capabilities, reachable_via, max_hops, None)
|
||||
}
|
||||
|
||||
/// Create announcement with an optional KeyPackage hash.
|
||||
pub fn with_keypackage(
|
||||
identity: &MeshIdentity,
|
||||
capabilities: u16,
|
||||
reachable_via: Vec<(String, Vec<u8>)>,
|
||||
max_hops: u8,
|
||||
keypackage_hash: Option<[u8; 8]>,
|
||||
) -> Self {
|
||||
let identity_key = identity.public_key().to_vec();
|
||||
let address = compute_address(&identity_key);
|
||||
@@ -75,6 +102,7 @@ impl MeshAnnounce {
|
||||
reachable_via,
|
||||
hop_count: 0,
|
||||
max_hops,
|
||||
keypackage_hash,
|
||||
signature: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -105,7 +133,7 @@ impl MeshAnnounce {
|
||||
/// hop_count without re-signing (same design as [`MeshEnvelope`]).
|
||||
fn signable_bytes(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(
|
||||
self.identity_key.len() + 16 + 2 + 8 + 8 + self.reachable_via.len() * 32 + 1,
|
||||
self.identity_key.len() + 16 + 2 + 8 + 8 + self.reachable_via.len() * 32 + 1 + 9,
|
||||
);
|
||||
buf.extend_from_slice(&self.identity_key);
|
||||
buf.extend_from_slice(&self.address);
|
||||
@@ -117,6 +145,13 @@ impl MeshAnnounce {
|
||||
buf.extend_from_slice(addr);
|
||||
}
|
||||
buf.push(self.max_hops);
|
||||
// Include keypackage_hash in signature if present
|
||||
if let Some(kp_hash) = &self.keypackage_hash {
|
||||
buf.push(1); // presence marker
|
||||
buf.extend_from_slice(kp_hash);
|
||||
} else {
|
||||
buf.push(0); // absence marker
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
|
||||
337
crates/quicprochat-p2p/src/crypto_negotiation.rs
Normal file
337
crates/quicprochat-p2p/src/crypto_negotiation.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
//! Crypto mode negotiation and upgrade path.
|
||||
//!
|
||||
//! This module handles transitions between crypto modes based on transport
|
||||
//! capability. Groups can upgrade from MLS-Lite to full MLS when a
|
||||
//! higher-bandwidth transport becomes available.
|
||||
//!
|
||||
//! # Upgrade Path
|
||||
//!
|
||||
//! ```text
|
||||
//! MLS-Lite (constrained) → Full MLS (when high-bandwidth available)
|
||||
//!
|
||||
//! 1. Group running MLS-Lite over LoRa
|
||||
//! 2. Member connects via WiFi/QUIC
|
||||
//! 3. Member sends MLS KeyPackage over fast link
|
||||
//! 4. Creator imports MLS-Lite members into MLS group
|
||||
//! 5. Sends MLS Welcome + epoch secret derivation
|
||||
//! 6. Group transitions to full MLS (can still use LoRa for app messages)
|
||||
//! ```
|
||||
//!
|
||||
//! # Security Considerations
|
||||
//!
|
||||
//! - Upgrade requires re-keying (new epoch in MLS)
|
||||
//! - Cannot downgrade without explicit action (security property)
|
||||
//! - MLS-Lite epoch secret can be derived from MLS export
|
||||
|
||||
use crate::mls_lite::MlsLiteGroup;
|
||||
use crate::transport::{CryptoMode, TransportCapability};
|
||||
|
||||
/// State of a group's crypto negotiation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GroupCryptoState {
|
||||
/// Group uses MLS-Lite with pre-shared key.
|
||||
MlsLite {
|
||||
group_id: [u8; 8],
|
||||
epoch: u16,
|
||||
signed: bool,
|
||||
},
|
||||
/// Group uses full MLS.
|
||||
FullMls {
|
||||
group_id: Vec<u8>,
|
||||
epoch: u64,
|
||||
hybrid_pq: bool,
|
||||
},
|
||||
/// Group is upgrading from MLS-Lite to full MLS.
|
||||
Upgrading {
|
||||
lite_group_id: [u8; 8],
|
||||
lite_epoch: u16,
|
||||
mls_group_id: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl GroupCryptoState {
|
||||
/// Current crypto mode.
|
||||
pub fn mode(&self) -> CryptoMode {
|
||||
match self {
|
||||
Self::MlsLite { signed: true, .. } => CryptoMode::MlsLiteSigned,
|
||||
Self::MlsLite { signed: false, .. } => CryptoMode::MlsLiteUnsigned,
|
||||
Self::FullMls { hybrid_pq: true, .. } => CryptoMode::MlsHybrid,
|
||||
Self::FullMls { hybrid_pq: false, .. } => CryptoMode::MlsClassical,
|
||||
Self::Upgrading { .. } => CryptoMode::MlsClassical, // Upgrading assumes MLS available
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if upgrade to full MLS is possible.
|
||||
pub fn can_upgrade(&self, available_capability: TransportCapability) -> bool {
|
||||
match self {
|
||||
Self::MlsLite { .. } => available_capability.supports_mls(),
|
||||
Self::FullMls { hybrid_pq: false, .. } => {
|
||||
// Can upgrade from classical MLS to hybrid if unconstrained
|
||||
available_capability == TransportCapability::Unconstrained
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this state supports the given transport capability.
|
||||
pub fn compatible_with(&self, capability: TransportCapability) -> bool {
|
||||
match self {
|
||||
Self::MlsLite { .. } => true, // MLS-Lite works on all transports
|
||||
Self::FullMls { hybrid_pq: true, .. } => {
|
||||
capability == TransportCapability::Unconstrained
|
||||
}
|
||||
Self::FullMls { hybrid_pq: false, .. } => capability.supports_mls(),
|
||||
Self::Upgrading { .. } => capability.supports_mls(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for deriving MLS-Lite key from MLS epoch secret.
|
||||
///
|
||||
/// This enables bootstrapping MLS-Lite from an existing MLS group.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MlsLiteBootstrap {
|
||||
/// MLS group ID (for domain separation).
|
||||
pub mls_group_id: Vec<u8>,
|
||||
/// MLS epoch from which to derive.
|
||||
pub mls_epoch: u64,
|
||||
/// Label for HKDF derivation.
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
impl MlsLiteBootstrap {
|
||||
/// Standard label for MLS-Lite derivation.
|
||||
pub const LABEL: &'static str = "quicprochat-mls-lite-from-mls";
|
||||
|
||||
/// Create bootstrap parameters from MLS group state.
|
||||
pub fn new(mls_group_id: Vec<u8>, mls_epoch: u64) -> Self {
|
||||
Self {
|
||||
mls_group_id,
|
||||
mls_epoch,
|
||||
label: Self::LABEL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive an MLS-Lite group secret from MLS epoch secret.
|
||||
///
|
||||
/// Uses HKDF with the epoch secret as input keying material.
|
||||
pub fn derive_lite_secret(&self, mls_epoch_secret: &[u8]) -> [u8; 32] {
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
|
||||
let salt = b"quicprochat-mls-lite-bootstrap-v1";
|
||||
let hk = Hkdf::<Sha256>::new(Some(salt), mls_epoch_secret);
|
||||
|
||||
let mut info = Vec::with_capacity(self.mls_group_id.len() + 8 + self.label.len());
|
||||
info.extend_from_slice(&self.mls_group_id);
|
||||
info.extend_from_slice(&self.mls_epoch.to_be_bytes());
|
||||
info.extend_from_slice(self.label.as_bytes());
|
||||
|
||||
let mut secret = [0u8; 32];
|
||||
hk.expand(&info, &mut secret)
|
||||
.expect("HKDF expand should not fail");
|
||||
secret
|
||||
}
|
||||
|
||||
/// Derive MLS-Lite group ID from MLS group ID.
|
||||
pub fn derive_lite_group_id(&self) -> [u8; 8] {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"mls-lite-group-id:");
|
||||
hasher.update(&self.mls_group_id);
|
||||
hasher.update(&self.mls_epoch.to_be_bytes());
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let mut id = [0u8; 8];
|
||||
id.copy_from_slice(&hash[..8]);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an MLS-Lite group derived from MLS epoch secret.
|
||||
///
|
||||
/// This enables constrained-link fallback for established MLS groups.
|
||||
pub fn create_lite_from_mls(
|
||||
mls_group_id: &[u8],
|
||||
mls_epoch: u64,
|
||||
mls_epoch_secret: &[u8],
|
||||
) -> MlsLiteGroup {
|
||||
let bootstrap = MlsLiteBootstrap::new(mls_group_id.to_vec(), mls_epoch);
|
||||
let lite_secret = bootstrap.derive_lite_secret(mls_epoch_secret);
|
||||
let lite_group_id = bootstrap.derive_lite_group_id();
|
||||
|
||||
MlsLiteGroup::new(lite_group_id, &lite_secret, 0)
|
||||
}
|
||||
|
||||
/// Upgrade request message sent when initiating MLS upgrade.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpgradeRequest {
|
||||
/// MLS-Lite group being upgraded.
|
||||
pub lite_group_id: [u8; 8],
|
||||
/// Current MLS-Lite epoch.
|
||||
pub lite_epoch: u16,
|
||||
/// Requester's MLS KeyPackage.
|
||||
pub keypackage: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Upgrade response with MLS Welcome for the upgrading member.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpgradeResponse {
|
||||
/// MLS-Lite group being upgraded.
|
||||
pub lite_group_id: [u8; 8],
|
||||
/// New MLS group ID.
|
||||
pub mls_group_id: Vec<u8>,
|
||||
/// MLS Welcome message for the requesting member.
|
||||
pub mls_welcome: Vec<u8>,
|
||||
/// Derived MLS-Lite secret for constrained links (optional).
|
||||
/// Allows continued MLS-Lite operation alongside full MLS.
|
||||
pub derived_lite_secret: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crypto_state_modes() {
|
||||
let lite_unsigned = GroupCryptoState::MlsLite {
|
||||
group_id: [0; 8],
|
||||
epoch: 0,
|
||||
signed: false,
|
||||
};
|
||||
assert_eq!(lite_unsigned.mode(), CryptoMode::MlsLiteUnsigned);
|
||||
|
||||
let lite_signed = GroupCryptoState::MlsLite {
|
||||
group_id: [0; 8],
|
||||
epoch: 0,
|
||||
signed: true,
|
||||
};
|
||||
assert_eq!(lite_signed.mode(), CryptoMode::MlsLiteSigned);
|
||||
|
||||
let mls_classical = GroupCryptoState::FullMls {
|
||||
group_id: vec![1, 2, 3],
|
||||
epoch: 5,
|
||||
hybrid_pq: false,
|
||||
};
|
||||
assert_eq!(mls_classical.mode(), CryptoMode::MlsClassical);
|
||||
|
||||
let mls_hybrid = GroupCryptoState::FullMls {
|
||||
group_id: vec![1, 2, 3],
|
||||
epoch: 5,
|
||||
hybrid_pq: true,
|
||||
};
|
||||
assert_eq!(mls_hybrid.mode(), CryptoMode::MlsHybrid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_upgrade_from_lite() {
|
||||
let lite = GroupCryptoState::MlsLite {
|
||||
group_id: [0; 8],
|
||||
epoch: 0,
|
||||
signed: true,
|
||||
};
|
||||
|
||||
// Can upgrade with unconstrained transport
|
||||
assert!(lite.can_upgrade(TransportCapability::Unconstrained));
|
||||
assert!(lite.can_upgrade(TransportCapability::Medium));
|
||||
|
||||
// Cannot upgrade with constrained transport
|
||||
assert!(!lite.can_upgrade(TransportCapability::Constrained));
|
||||
assert!(!lite.can_upgrade(TransportCapability::SeverelyConstrained));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_upgrade_classical_to_hybrid() {
|
||||
let classical = GroupCryptoState::FullMls {
|
||||
group_id: vec![1, 2, 3],
|
||||
epoch: 5,
|
||||
hybrid_pq: false,
|
||||
};
|
||||
|
||||
assert!(classical.can_upgrade(TransportCapability::Unconstrained));
|
||||
assert!(!classical.can_upgrade(TransportCapability::Medium));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_derivation() {
|
||||
let mls_group_id = b"test-mls-group".to_vec();
|
||||
let mls_epoch = 42u64;
|
||||
let mls_secret = [0x42u8; 32];
|
||||
|
||||
let bootstrap = MlsLiteBootstrap::new(mls_group_id.clone(), mls_epoch);
|
||||
|
||||
// Secret derivation should be deterministic
|
||||
let secret1 = bootstrap.derive_lite_secret(&mls_secret);
|
||||
let secret2 = bootstrap.derive_lite_secret(&mls_secret);
|
||||
assert_eq!(secret1, secret2);
|
||||
|
||||
// Different epoch should give different secret
|
||||
let bootstrap2 = MlsLiteBootstrap::new(mls_group_id, mls_epoch + 1);
|
||||
let secret3 = bootstrap2.derive_lite_secret(&mls_secret);
|
||||
assert_ne!(secret1, secret3);
|
||||
|
||||
// Group ID derivation
|
||||
let lite_id = bootstrap.derive_lite_group_id();
|
||||
assert_eq!(lite_id.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_lite_from_mls_works() {
|
||||
let mls_group_id = b"mls-group-123".to_vec();
|
||||
let mls_epoch = 10;
|
||||
let mls_secret = [0xABu8; 32];
|
||||
|
||||
let lite_group = create_lite_from_mls(&mls_group_id, mls_epoch, &mls_secret);
|
||||
|
||||
// Should be able to encrypt/decrypt
|
||||
let mut alice = lite_group;
|
||||
let mut bob = create_lite_from_mls(&mls_group_id, mls_epoch, &mls_secret);
|
||||
|
||||
let (ct, nonce, _seq) = alice.encrypt(b"hello from alice").expect("encrypt");
|
||||
|
||||
use crate::address::MeshAddress;
|
||||
let alice_addr = MeshAddress::from_bytes([0xAA; 16]);
|
||||
|
||||
match bob.decrypt(&ct, &nonce, alice_addr) {
|
||||
crate::mls_lite::DecryptResult::Success(pt) => {
|
||||
assert_eq!(pt, b"hello from alice");
|
||||
}
|
||||
other => panic!("expected Success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_check() {
|
||||
let lite = GroupCryptoState::MlsLite {
|
||||
group_id: [0; 8],
|
||||
epoch: 0,
|
||||
signed: true,
|
||||
};
|
||||
|
||||
// MLS-Lite works on all transports
|
||||
assert!(lite.compatible_with(TransportCapability::Unconstrained));
|
||||
assert!(lite.compatible_with(TransportCapability::SeverelyConstrained));
|
||||
|
||||
let mls_hybrid = GroupCryptoState::FullMls {
|
||||
group_id: vec![1],
|
||||
epoch: 1,
|
||||
hybrid_pq: true,
|
||||
};
|
||||
|
||||
// PQ-hybrid only works on unconstrained
|
||||
assert!(mls_hybrid.compatible_with(TransportCapability::Unconstrained));
|
||||
assert!(!mls_hybrid.compatible_with(TransportCapability::Medium));
|
||||
|
||||
let mls_classical = GroupCryptoState::FullMls {
|
||||
group_id: vec![1],
|
||||
epoch: 1,
|
||||
hybrid_pq: false,
|
||||
};
|
||||
|
||||
// Classical MLS works on medium+
|
||||
assert!(mls_classical.compatible_with(TransportCapability::Unconstrained));
|
||||
assert!(mls_classical.compatible_with(TransportCapability::Medium));
|
||||
assert!(!mls_classical.compatible_with(TransportCapability::Constrained));
|
||||
}
|
||||
}
|
||||
360
crates/quicprochat-p2p/src/keypackage_cache.rs
Normal file
360
crates/quicprochat-p2p/src/keypackage_cache.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
//! KeyPackage cache for mesh-based MLS group setup.
|
||||
//!
|
||||
//! The [`KeyPackageCache`] stores MLS KeyPackages received from other nodes,
|
||||
//! enabling group creation without a central server. KeyPackages are:
|
||||
//!
|
||||
//! - Indexed by the node's 16-byte mesh address
|
||||
//! - Hashed (8 bytes) for announce inclusion
|
||||
//! - TTL-managed for expiry (MLS KeyPackages are single-use but we cache N of them)
|
||||
//! - Bounded by capacity to prevent memory exhaustion
|
||||
//!
|
||||
//! # Protocol Flow
|
||||
//!
|
||||
//! 1. Bob generates KeyPackage, computes hash, includes hash in MeshAnnounce
|
||||
//! 2. Bob broadcasts full KeyPackage periodically (or on request)
|
||||
//! 3. Alice receives Bob's KeyPackage, stores in cache
|
||||
//! 4. Alice wants to create group with Bob: fetches from cache, creates Welcome
|
||||
//! 5. Alice sends Welcome to Bob via mesh routing
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::address::MeshAddress;
|
||||
use crate::announce::compute_keypackage_hash;
|
||||
|
||||
/// Default TTL for cached KeyPackages (24 hours).
|
||||
const DEFAULT_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Default maximum KeyPackages per address (allow rotation).
|
||||
const DEFAULT_MAX_PER_ADDRESS: usize = 3;
|
||||
|
||||
/// A cached KeyPackage entry.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CachedKeyPackage {
|
||||
/// The serialized MLS KeyPackage bytes.
|
||||
pub bytes: Vec<u8>,
|
||||
/// 8-byte truncated hash for matching against announces.
|
||||
pub hash: [u8; 8],
|
||||
/// When this entry was stored.
|
||||
pub stored_at: Instant,
|
||||
/// When this entry expires.
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
impl CachedKeyPackage {
|
||||
/// Create a new cached entry with default TTL.
|
||||
pub fn new(bytes: Vec<u8>) -> Self {
|
||||
Self::with_ttl(bytes, DEFAULT_TTL)
|
||||
}
|
||||
|
||||
/// Create with custom TTL.
|
||||
pub fn with_ttl(bytes: Vec<u8>, ttl: Duration) -> Self {
|
||||
let hash = compute_keypackage_hash(&bytes);
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
bytes,
|
||||
hash,
|
||||
stored_at: now,
|
||||
expires_at: now + ttl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this entry has expired.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Instant::now() > self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache for KeyPackages received from mesh peers.
|
||||
pub struct KeyPackageCache {
|
||||
/// Address -> list of cached KeyPackages (multiple for rotation).
|
||||
entries: HashMap<MeshAddress, Vec<CachedKeyPackage>>,
|
||||
/// Maximum KeyPackages stored per address.
|
||||
max_per_address: usize,
|
||||
/// Total capacity (max addresses).
|
||||
max_addresses: usize,
|
||||
}
|
||||
|
||||
impl KeyPackageCache {
|
||||
/// Create a new cache with default settings.
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(1000, DEFAULT_MAX_PER_ADDRESS)
|
||||
}
|
||||
|
||||
/// Create with custom capacity.
|
||||
pub fn with_capacity(max_addresses: usize, max_per_address: usize) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
max_per_address,
|
||||
max_addresses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a KeyPackage for a given address.
|
||||
///
|
||||
/// Returns `true` if stored, `false` if rejected (at capacity or duplicate hash).
|
||||
pub fn store(&mut self, address: MeshAddress, keypackage_bytes: Vec<u8>) -> bool {
|
||||
let entry = CachedKeyPackage::new(keypackage_bytes);
|
||||
self.store_entry(address, entry)
|
||||
}
|
||||
|
||||
/// Store a KeyPackage entry.
|
||||
fn store_entry(&mut self, address: MeshAddress, entry: CachedKeyPackage) -> bool {
|
||||
// Check if we already have this exact KeyPackage
|
||||
if let Some(existing) = self.entries.get(&address) {
|
||||
if existing.iter().any(|e| e.hash == entry.hash) {
|
||||
return false; // Duplicate
|
||||
}
|
||||
}
|
||||
|
||||
// Check total capacity
|
||||
if !self.entries.contains_key(&address) && self.entries.len() >= self.max_addresses {
|
||||
// Evict oldest entry
|
||||
self.evict_oldest();
|
||||
}
|
||||
|
||||
let list = self.entries.entry(address).or_default();
|
||||
|
||||
// Enforce per-address limit
|
||||
while list.len() >= self.max_per_address {
|
||||
list.remove(0); // Remove oldest
|
||||
}
|
||||
|
||||
list.push(entry);
|
||||
true
|
||||
}
|
||||
|
||||
/// Get the newest KeyPackage for an address.
|
||||
pub fn get(&self, address: &MeshAddress) -> Option<&CachedKeyPackage> {
|
||||
self.entries
|
||||
.get(address)
|
||||
.and_then(|list| list.iter().rev().find(|e| !e.is_expired()))
|
||||
}
|
||||
|
||||
/// Get a KeyPackage by its hash.
|
||||
pub fn get_by_hash(&self, address: &MeshAddress, hash: &[u8; 8]) -> Option<&CachedKeyPackage> {
|
||||
self.entries.get(address).and_then(|list| {
|
||||
list.iter()
|
||||
.rev()
|
||||
.find(|e| &e.hash == hash && !e.is_expired())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the newest KeyPackage bytes for an address.
|
||||
pub fn get_bytes(&self, address: &MeshAddress) -> Option<Vec<u8>> {
|
||||
self.get(address).map(|e| e.bytes.clone())
|
||||
}
|
||||
|
||||
/// Check if we have a KeyPackage matching a given hash.
|
||||
pub fn has_hash(&self, address: &MeshAddress, hash: &[u8; 8]) -> bool {
|
||||
self.get_by_hash(address, hash).is_some()
|
||||
}
|
||||
|
||||
/// Remove all expired entries. Returns count removed.
|
||||
pub fn gc_expired(&mut self) -> usize {
|
||||
let mut removed = 0;
|
||||
self.entries.retain(|_, list| {
|
||||
let before = list.len();
|
||||
list.retain(|e| !e.is_expired());
|
||||
removed += before - list.len();
|
||||
!list.is_empty()
|
||||
});
|
||||
removed
|
||||
}
|
||||
|
||||
/// Evict the oldest entry across all addresses.
|
||||
fn evict_oldest(&mut self) {
|
||||
let oldest_addr = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|(addr, list)| {
|
||||
list.first().map(|e| (addr.clone(), e.stored_at))
|
||||
})
|
||||
.min_by_key(|(_, stored)| *stored)
|
||||
.map(|(addr, _)| addr);
|
||||
|
||||
if let Some(addr) = oldest_addr {
|
||||
if let Some(list) = self.entries.get_mut(&addr) {
|
||||
list.remove(0);
|
||||
if list.is_empty() {
|
||||
self.entries.remove(&addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of addresses with cached KeyPackages.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Whether the cache is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Total number of cached KeyPackages.
|
||||
pub fn total_keypackages(&self) -> usize {
|
||||
self.entries.values().map(|v| v.len()).sum()
|
||||
}
|
||||
|
||||
/// Consume a KeyPackage (remove after use, as MLS KeyPackages are single-use).
|
||||
///
|
||||
/// Returns the KeyPackage bytes if found.
|
||||
pub fn consume(&mut self, address: &MeshAddress, hash: &[u8; 8]) -> Option<Vec<u8>> {
|
||||
let list = self.entries.get_mut(address)?;
|
||||
let idx = list.iter().position(|e| &e.hash == hash)?;
|
||||
let entry = list.remove(idx);
|
||||
if list.is_empty() {
|
||||
self.entries.remove(address);
|
||||
}
|
||||
Some(entry.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KeyPackageCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_keypackage(seed: u8) -> Vec<u8> {
|
||||
vec![seed; 100 + seed as usize]
|
||||
}
|
||||
|
||||
fn make_address(seed: u8) -> MeshAddress {
|
||||
MeshAddress::from_bytes([seed; 16])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_retrieve() {
|
||||
let mut cache = KeyPackageCache::new();
|
||||
let addr = make_address(1);
|
||||
let kp = make_keypackage(1);
|
||||
let hash = compute_keypackage_hash(&kp);
|
||||
|
||||
assert!(cache.store(addr, kp.clone()));
|
||||
assert_eq!(cache.len(), 1);
|
||||
|
||||
let retrieved = cache.get(&addr).expect("should exist");
|
||||
assert_eq!(retrieved.bytes, kp);
|
||||
assert_eq!(retrieved.hash, hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_duplicate() {
|
||||
let mut cache = KeyPackageCache::new();
|
||||
let addr = make_address(2);
|
||||
let kp = make_keypackage(2);
|
||||
|
||||
assert!(cache.store(addr, kp.clone()));
|
||||
assert!(!cache.store(addr, kp), "duplicate should be rejected");
|
||||
assert_eq!(cache.total_keypackages(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_per_address() {
|
||||
let mut cache = KeyPackageCache::with_capacity(100, 3);
|
||||
let addr = make_address(3);
|
||||
|
||||
assert!(cache.store(addr, make_keypackage(1)));
|
||||
assert!(cache.store(addr, make_keypackage(2)));
|
||||
assert!(cache.store(addr, make_keypackage(3)));
|
||||
assert_eq!(cache.total_keypackages(), 3);
|
||||
|
||||
// Fourth should evict first
|
||||
assert!(cache.store(addr, make_keypackage(4)));
|
||||
assert_eq!(cache.total_keypackages(), 3);
|
||||
|
||||
// First should be gone
|
||||
let hash1 = compute_keypackage_hash(&make_keypackage(1));
|
||||
assert!(!cache.has_hash(&addr, &hash1));
|
||||
|
||||
// Fourth should be present
|
||||
let hash4 = compute_keypackage_hash(&make_keypackage(4));
|
||||
assert!(cache.has_hash(&addr, &hash4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consume_removes_keypackage() {
|
||||
let mut cache = KeyPackageCache::new();
|
||||
let addr = make_address(4);
|
||||
let kp = make_keypackage(4);
|
||||
let hash = compute_keypackage_hash(&kp);
|
||||
|
||||
cache.store(addr, kp.clone());
|
||||
assert!(cache.has_hash(&addr, &hash));
|
||||
|
||||
let consumed = cache.consume(&addr, &hash).expect("should consume");
|
||||
assert_eq!(consumed, kp);
|
||||
assert!(!cache.has_hash(&addr, &hash));
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_hash() {
|
||||
let mut cache = KeyPackageCache::new();
|
||||
let addr = make_address(5);
|
||||
let kp1 = make_keypackage(51);
|
||||
let kp2 = make_keypackage(52);
|
||||
let hash1 = compute_keypackage_hash(&kp1);
|
||||
let hash2 = compute_keypackage_hash(&kp2);
|
||||
|
||||
cache.store(addr, kp1.clone());
|
||||
cache.store(addr, kp2.clone());
|
||||
|
||||
let found1 = cache.get_by_hash(&addr, &hash1).expect("hash1");
|
||||
assert_eq!(found1.bytes, kp1);
|
||||
|
||||
let found2 = cache.get_by_hash(&addr, &hash2).expect("hash2");
|
||||
assert_eq!(found2.bytes, kp2);
|
||||
|
||||
let wrong_hash = [0xFFu8; 8];
|
||||
assert!(cache.get_by_hash(&addr, &wrong_hash).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_eviction() {
|
||||
let mut cache = KeyPackageCache::with_capacity(2, 1);
|
||||
|
||||
let addr1 = make_address(1);
|
||||
let addr2 = make_address(2);
|
||||
let addr3 = make_address(3);
|
||||
|
||||
cache.store(addr1, make_keypackage(1));
|
||||
cache.store(addr2, make_keypackage(2));
|
||||
assert_eq!(cache.len(), 2);
|
||||
|
||||
// Third should evict oldest (addr1)
|
||||
cache.store(addr3, make_keypackage(3));
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert!(cache.get(&addr1).is_none());
|
||||
assert!(cache.get(&addr2).is_some());
|
||||
assert!(cache.get(&addr3).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry() {
|
||||
let mut cache = KeyPackageCache::new();
|
||||
let addr = make_address(6);
|
||||
|
||||
// Create entry with very short TTL
|
||||
let kp = make_keypackage(6);
|
||||
let entry = CachedKeyPackage::with_ttl(kp, Duration::from_millis(1));
|
||||
cache.store_entry(addr, entry);
|
||||
|
||||
assert_eq!(cache.total_keypackages(), 1);
|
||||
|
||||
// Wait for expiry
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
// GC should remove it
|
||||
let removed = cache.gc_expired();
|
||||
assert_eq!(removed, 1);
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,14 @@
|
||||
pub mod address;
|
||||
pub mod announce;
|
||||
pub mod announce_protocol;
|
||||
pub mod crypto_negotiation;
|
||||
pub mod fapp;
|
||||
pub mod fapp_router;
|
||||
pub mod broadcast;
|
||||
pub mod envelope;
|
||||
pub mod envelope_v2;
|
||||
pub mod keypackage_cache;
|
||||
pub mod mesh_protocol;
|
||||
pub mod mls_lite;
|
||||
pub mod identity;
|
||||
pub mod link;
|
||||
|
||||
269
crates/quicprochat-p2p/src/mesh_protocol.rs
Normal file
269
crates/quicprochat-p2p/src/mesh_protocol.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Mesh protocol messages for peer-to-peer communication.
|
||||
//!
|
||||
//! This module defines the control messages used for mesh coordination:
|
||||
//! - KeyPackage request/response for MLS group setup
|
||||
//! - Future: route requests, capability queries, etc.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::address::MeshAddress;
|
||||
|
||||
/// Protocol message type discriminator.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum MessageType {
|
||||
/// Request a KeyPackage from a node.
|
||||
KeyPackageRequest = 0x10,
|
||||
/// Response with KeyPackage data.
|
||||
KeyPackageResponse = 0x11,
|
||||
/// Node has no KeyPackage available.
|
||||
KeyPackageUnavailable = 0x12,
|
||||
}
|
||||
|
||||
/// Request a KeyPackage from a peer.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct KeyPackageRequest {
|
||||
/// Who is requesting.
|
||||
pub requester_addr: MeshAddress,
|
||||
/// Whose KeyPackage is requested.
|
||||
pub target_addr: MeshAddress,
|
||||
/// Optional: specific hash to request (from announce).
|
||||
pub hash: Option<[u8; 8]>,
|
||||
/// Request ID for correlation.
|
||||
pub request_id: u32,
|
||||
}
|
||||
|
||||
impl KeyPackageRequest {
|
||||
/// Create a new request.
|
||||
pub fn new(requester: MeshAddress, target: MeshAddress) -> Self {
|
||||
Self {
|
||||
requester_addr: requester,
|
||||
target_addr: target,
|
||||
hash: None,
|
||||
request_id: rand::random(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with specific hash.
|
||||
pub fn with_hash(requester: MeshAddress, target: MeshAddress, hash: [u8; 8]) -> Self {
|
||||
Self {
|
||||
requester_addr: requester,
|
||||
target_addr: target,
|
||||
hash: Some(hash),
|
||||
request_id: rand::random(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to CBOR.
|
||||
pub fn to_wire(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
buf.push(MessageType::KeyPackageRequest as u8);
|
||||
ciborium::into_writer(self, &mut buf).expect("CBOR serialization");
|
||||
buf
|
||||
}
|
||||
|
||||
/// Deserialize from CBOR (after type byte).
|
||||
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
if bytes.is_empty() || bytes[0] != MessageType::KeyPackageRequest as u8 {
|
||||
anyhow::bail!("not a KeyPackageRequest");
|
||||
}
|
||||
let req: Self = ciborium::from_reader(&bytes[1..])?;
|
||||
Ok(req)
|
||||
}
|
||||
}
|
||||
|
||||
/// Response with KeyPackage data.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct KeyPackageResponse {
|
||||
/// Whose KeyPackage this is.
|
||||
pub owner_addr: MeshAddress,
|
||||
/// The serialized MLS KeyPackage.
|
||||
pub keypackage_bytes: Vec<u8>,
|
||||
/// Hash of the KeyPackage (for verification).
|
||||
pub hash: [u8; 8],
|
||||
/// Matching request ID.
|
||||
pub request_id: u32,
|
||||
}
|
||||
|
||||
impl KeyPackageResponse {
|
||||
/// Create a new response.
|
||||
pub fn new(
|
||||
owner: MeshAddress,
|
||||
keypackage_bytes: Vec<u8>,
|
||||
request_id: u32,
|
||||
) -> Self {
|
||||
let hash = crate::announce::compute_keypackage_hash(&keypackage_bytes);
|
||||
Self {
|
||||
owner_addr: owner,
|
||||
keypackage_bytes,
|
||||
hash,
|
||||
request_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to CBOR.
|
||||
pub fn to_wire(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
buf.push(MessageType::KeyPackageResponse as u8);
|
||||
ciborium::into_writer(self, &mut buf).expect("CBOR serialization");
|
||||
buf
|
||||
}
|
||||
|
||||
/// Deserialize from CBOR (after type byte).
|
||||
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
if bytes.is_empty() || bytes[0] != MessageType::KeyPackageResponse as u8 {
|
||||
anyhow::bail!("not a KeyPackageResponse");
|
||||
}
|
||||
let resp: Self = ciborium::from_reader(&bytes[1..])?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Verify the hash matches the KeyPackage.
|
||||
pub fn verify_hash(&self) -> bool {
|
||||
let computed = crate::announce::compute_keypackage_hash(&self.keypackage_bytes);
|
||||
computed == self.hash
|
||||
}
|
||||
}
|
||||
|
||||
/// Response indicating no KeyPackage available.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct KeyPackageUnavailable {
|
||||
/// Whose KeyPackage was requested.
|
||||
pub target_addr: MeshAddress,
|
||||
/// Matching request ID.
|
||||
pub request_id: u32,
|
||||
}
|
||||
|
||||
impl KeyPackageUnavailable {
|
||||
/// Create a new unavailable response.
|
||||
pub fn new(target: MeshAddress, request_id: u32) -> Self {
|
||||
Self {
|
||||
target_addr: target,
|
||||
request_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to CBOR.
|
||||
pub fn to_wire(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
buf.push(MessageType::KeyPackageUnavailable as u8);
|
||||
ciborium::into_writer(self, &mut buf).expect("CBOR serialization");
|
||||
buf
|
||||
}
|
||||
|
||||
/// Deserialize from CBOR (after type byte).
|
||||
pub fn from_wire(bytes: &[u8]) -> anyhow::Result<Self> {
|
||||
if bytes.is_empty() || bytes[0] != MessageType::KeyPackageUnavailable as u8 {
|
||||
anyhow::bail!("not a KeyPackageUnavailable");
|
||||
}
|
||||
let resp: Self = ciborium::from_reader(&bytes[1..])?;
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the message type from wire bytes.
|
||||
pub fn parse_message_type(bytes: &[u8]) -> Option<MessageType> {
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match bytes[0] {
|
||||
0x10 => Some(MessageType::KeyPackageRequest),
|
||||
0x11 => Some(MessageType::KeyPackageResponse),
|
||||
0x12 => Some(MessageType::KeyPackageUnavailable),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_address(seed: u8) -> MeshAddress {
|
||||
MeshAddress::from_bytes([seed; 16])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_roundtrip() {
|
||||
let req = KeyPackageRequest::new(make_address(1), make_address(2));
|
||||
let wire = req.to_wire();
|
||||
let restored = KeyPackageRequest::from_wire(&wire).expect("parse");
|
||||
|
||||
assert_eq!(req.requester_addr, restored.requester_addr);
|
||||
assert_eq!(req.target_addr, restored.target_addr);
|
||||
assert_eq!(req.request_id, restored.request_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_with_hash_roundtrip() {
|
||||
let hash = [0xAB; 8];
|
||||
let req = KeyPackageRequest::with_hash(make_address(1), make_address(2), hash);
|
||||
let wire = req.to_wire();
|
||||
let restored = KeyPackageRequest::from_wire(&wire).expect("parse");
|
||||
|
||||
assert_eq!(req.hash, restored.hash);
|
||||
assert_eq!(Some(hash), restored.hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_roundtrip() {
|
||||
let kp_bytes = vec![0x42; 100];
|
||||
let resp = KeyPackageResponse::new(make_address(3), kp_bytes.clone(), 12345);
|
||||
let wire = resp.to_wire();
|
||||
let restored = KeyPackageResponse::from_wire(&wire).expect("parse");
|
||||
|
||||
assert_eq!(resp.owner_addr, restored.owner_addr);
|
||||
assert_eq!(resp.keypackage_bytes, restored.keypackage_bytes);
|
||||
assert_eq!(resp.hash, restored.hash);
|
||||
assert_eq!(resp.request_id, restored.request_id);
|
||||
assert!(restored.verify_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_roundtrip() {
|
||||
let resp = KeyPackageUnavailable::new(make_address(4), 99999);
|
||||
let wire = resp.to_wire();
|
||||
let restored = KeyPackageUnavailable::from_wire(&wire).expect("parse");
|
||||
|
||||
assert_eq!(resp.target_addr, restored.target_addr);
|
||||
assert_eq!(resp.request_id, restored.request_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_message_type_works() {
|
||||
let req = KeyPackageRequest::new(make_address(1), make_address(2));
|
||||
let wire = req.to_wire();
|
||||
assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageRequest));
|
||||
|
||||
let resp = KeyPackageResponse::new(make_address(3), vec![0x42], 1);
|
||||
let wire = resp.to_wire();
|
||||
assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageResponse));
|
||||
|
||||
let unavail = KeyPackageUnavailable::new(make_address(4), 2);
|
||||
let wire = unavail.to_wire();
|
||||
assert_eq!(parse_message_type(&wire), Some(MessageType::KeyPackageUnavailable));
|
||||
|
||||
assert_eq!(parse_message_type(&[]), None);
|
||||
assert_eq!(parse_message_type(&[0xFF]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_protocol_overhead() {
|
||||
let req = KeyPackageRequest::new(make_address(1), make_address(2));
|
||||
let wire = req.to_wire();
|
||||
println!("KeyPackageRequest: {} bytes", wire.len());
|
||||
|
||||
let kp_bytes = vec![0x42; 306]; // Typical MLS KeyPackage size
|
||||
let resp = KeyPackageResponse::new(make_address(3), kp_bytes.clone(), 12345);
|
||||
let wire = resp.to_wire();
|
||||
println!("KeyPackageResponse (306B payload): {} bytes", wire.len());
|
||||
println!("Response overhead: {} bytes", wire.len() - 306);
|
||||
|
||||
let unavail = KeyPackageUnavailable::new(make_address(4), 99999);
|
||||
let wire = unavail.to_wire();
|
||||
println!("KeyPackageUnavailable: {} bytes", wire.len());
|
||||
|
||||
// Assertions
|
||||
assert!(req.to_wire().len() < 100, "request should be compact");
|
||||
assert!(unavail.to_wire().len() < 50, "unavailable should be compact");
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,77 @@ impl fmt::Display for TransportAddr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport capability level for crypto mode selection.
|
||||
///
|
||||
/// Ordered from worst to best so max_by_key picks the best transport.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TransportCapability {
|
||||
/// Very low bandwidth, severely duty-cycled (LoRa SF11-SF12, serial).
|
||||
/// MLS-Lite without signature preferred.
|
||||
SeverelyConstrained = 0,
|
||||
/// Low bandwidth, duty-cycled (LoRa SF7-SF10).
|
||||
/// Classical MLS marginal, prefer MLS-Lite with sig.
|
||||
Constrained = 1,
|
||||
/// Medium bandwidth (BLE, slower WiFi).
|
||||
/// Supports full MLS with classical crypto.
|
||||
Medium = 2,
|
||||
/// High-bandwidth, low-latency (QUIC, TCP, WiFi).
|
||||
/// Supports full MLS with PQ-KEM, large KeyPackages.
|
||||
Unconstrained = 3,
|
||||
}
|
||||
|
||||
impl TransportCapability {
|
||||
/// Determine capability from bitrate and MTU.
|
||||
pub fn from_metrics(bitrate_bps: u64, mtu: usize) -> Self {
|
||||
match (bitrate_bps, mtu) {
|
||||
(b, _) if b >= 1_000_000 => Self::Unconstrained, // ≥1 Mbps
|
||||
(b, m) if b >= 10_000 && m >= 200 => Self::Medium, // ≥10 kbps, decent MTU
|
||||
(b, m) if b >= 1_000 || m >= 100 => Self::Constrained, // ≥1 kbps
|
||||
_ => Self::SeverelyConstrained,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recommended crypto mode for this capability level.
|
||||
pub fn recommended_crypto(&self) -> CryptoMode {
|
||||
match self {
|
||||
Self::Unconstrained => CryptoMode::MlsHybrid,
|
||||
Self::Medium => CryptoMode::MlsClassical,
|
||||
Self::Constrained => CryptoMode::MlsLiteSigned,
|
||||
Self::SeverelyConstrained => CryptoMode::MlsLiteUnsigned,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether full MLS is viable on this transport.
|
||||
pub fn supports_mls(&self) -> bool {
|
||||
matches!(self, Self::Unconstrained | Self::Medium)
|
||||
}
|
||||
}
|
||||
|
||||
/// Crypto mode for mesh messaging.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CryptoMode {
|
||||
/// Full MLS with X25519 + ML-KEM-768 hybrid.
|
||||
MlsHybrid,
|
||||
/// Full MLS with classical X25519 only.
|
||||
MlsClassical,
|
||||
/// MLS-Lite with Ed25519 signature.
|
||||
MlsLiteSigned,
|
||||
/// MLS-Lite without signature (smallest overhead).
|
||||
MlsLiteUnsigned,
|
||||
}
|
||||
|
||||
impl CryptoMode {
|
||||
/// Approximate overhead in bytes for this mode.
|
||||
pub fn overhead_bytes(&self) -> usize {
|
||||
match self {
|
||||
Self::MlsHybrid => 2700, // PQ KeyPackage alone
|
||||
Self::MlsClassical => 400, // Classical KeyPackage + message
|
||||
Self::MlsLiteSigned => 262, // MLS-Lite with sig
|
||||
Self::MlsLiteUnsigned => 129, // MLS-Lite minimal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata about a transport's capabilities.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TransportInfo {
|
||||
@@ -48,6 +119,18 @@ pub struct TransportInfo {
|
||||
pub bidirectional: bool,
|
||||
}
|
||||
|
||||
impl TransportInfo {
|
||||
/// Compute capability level from this transport's metrics.
|
||||
pub fn capability(&self) -> TransportCapability {
|
||||
TransportCapability::from_metrics(self.bitrate, self.mtu)
|
||||
}
|
||||
|
||||
/// Recommended crypto mode for this transport.
|
||||
pub fn recommended_crypto(&self) -> CryptoMode {
|
||||
self.capability().recommended_crypto()
|
||||
}
|
||||
}
|
||||
|
||||
/// Received packet from a transport.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TransportPacket {
|
||||
@@ -137,4 +220,70 @@ mod tests {
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_ordering() {
|
||||
// Higher value = better capability
|
||||
assert!(TransportCapability::Unconstrained > TransportCapability::Medium);
|
||||
assert!(TransportCapability::Medium > TransportCapability::Constrained);
|
||||
assert!(TransportCapability::Constrained > TransportCapability::SeverelyConstrained);
|
||||
|
||||
// max_by_key should pick the best
|
||||
let caps = vec![
|
||||
TransportCapability::Constrained,
|
||||
TransportCapability::Unconstrained,
|
||||
TransportCapability::Medium,
|
||||
];
|
||||
let best = caps.into_iter().max().unwrap();
|
||||
assert_eq!(best, TransportCapability::Unconstrained);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_recommended_crypto() {
|
||||
assert_eq!(
|
||||
TransportCapability::Unconstrained.recommended_crypto(),
|
||||
CryptoMode::MlsHybrid
|
||||
);
|
||||
assert_eq!(
|
||||
TransportCapability::Medium.recommended_crypto(),
|
||||
CryptoMode::MlsClassical
|
||||
);
|
||||
assert_eq!(
|
||||
TransportCapability::Constrained.recommended_crypto(),
|
||||
CryptoMode::MlsLiteSigned
|
||||
);
|
||||
assert_eq!(
|
||||
TransportCapability::SeverelyConstrained.recommended_crypto(),
|
||||
CryptoMode::MlsLiteUnsigned
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_info_capability() {
|
||||
let tcp_info = TransportInfo {
|
||||
name: "tcp".to_string(),
|
||||
mtu: 1500,
|
||||
bitrate: 100_000_000, // 100 Mbps
|
||||
bidirectional: true,
|
||||
};
|
||||
assert_eq!(tcp_info.capability(), TransportCapability::Unconstrained);
|
||||
assert_eq!(tcp_info.recommended_crypto(), CryptoMode::MlsHybrid);
|
||||
|
||||
let lora_info = TransportInfo {
|
||||
name: "lora".to_string(),
|
||||
mtu: 51,
|
||||
bitrate: 300,
|
||||
bidirectional: true,
|
||||
};
|
||||
assert_eq!(lora_info.capability(), TransportCapability::SeverelyConstrained);
|
||||
assert_eq!(lora_info.recommended_crypto(), CryptoMode::MlsLiteUnsigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_mode_overhead() {
|
||||
assert!(CryptoMode::MlsHybrid.overhead_bytes() > 2000);
|
||||
assert!(CryptoMode::MlsClassical.overhead_bytes() < 500);
|
||||
assert!(CryptoMode::MlsLiteSigned.overhead_bytes() < 300);
|
||||
assert!(CryptoMode::MlsLiteUnsigned.overhead_bytes() < 150);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::transport::{MeshTransport, TransportAddr, TransportInfo};
|
||||
use crate::transport::{CryptoMode, MeshTransport, TransportAddr, TransportCapability, TransportInfo};
|
||||
|
||||
/// Manages multiple mesh transports and routes packets to the best available one.
|
||||
pub struct TransportManager {
|
||||
@@ -81,6 +81,63 @@ impl TransportManager {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the best (highest capability) transport available.
|
||||
pub fn best_transport(&self) -> Option<&dyn MeshTransport> {
|
||||
self.transports
|
||||
.iter()
|
||||
.max_by_key(|t| t.info().capability())
|
||||
.map(|t| t.as_ref())
|
||||
}
|
||||
|
||||
/// Get the capability level of the best available transport.
|
||||
pub fn best_capability(&self) -> Option<TransportCapability> {
|
||||
self.best_transport().map(|t| t.info().capability())
|
||||
}
|
||||
|
||||
/// Get the recommended crypto mode based on best available transport.
|
||||
pub fn recommended_crypto(&self) -> CryptoMode {
|
||||
self.best_capability()
|
||||
.map(|c| c.recommended_crypto())
|
||||
.unwrap_or(CryptoMode::MlsLiteUnsigned)
|
||||
}
|
||||
|
||||
/// Check if any transport supports full MLS.
|
||||
pub fn supports_mls(&self) -> bool {
|
||||
self.transports.iter().any(|t| t.info().capability().supports_mls())
|
||||
}
|
||||
|
||||
/// Get the capability level for a specific transport name.
|
||||
pub fn capability_for(&self, name: &str) -> Option<TransportCapability> {
|
||||
self.transports
|
||||
.iter()
|
||||
.find(|t| t.info().name == name)
|
||||
.map(|t| t.info().capability())
|
||||
}
|
||||
|
||||
/// Select the best transport for a given data size.
|
||||
///
|
||||
/// Prefers transports where the data fits in one MTU.
|
||||
/// Falls back to highest-capability transport if fragmentation is needed.
|
||||
pub fn select_for_size(&self, data_size: usize) -> Option<&dyn MeshTransport> {
|
||||
// First, try transports where data fits in MTU
|
||||
let fits: Vec<_> = self
|
||||
.transports
|
||||
.iter()
|
||||
.filter(|t| t.info().mtu >= data_size)
|
||||
.collect();
|
||||
|
||||
if !fits.is_empty() {
|
||||
// Among those that fit, prefer highest capability
|
||||
return fits
|
||||
.into_iter()
|
||||
.max_by_key(|t| t.info().capability())
|
||||
.map(|t| t.as_ref());
|
||||
}
|
||||
|
||||
// Nothing fits — return highest capability (will need fragmentation)
|
||||
self.best_transport()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TransportManager {
|
||||
@@ -178,4 +235,105 @@ mod tests {
|
||||
let result = mgr.close_all().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
struct MockLoRaTransport;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MeshTransport for MockLoRaTransport {
|
||||
fn info(&self) -> TransportInfo {
|
||||
TransportInfo {
|
||||
name: "lora".to_string(),
|
||||
mtu: 51, // SF12 LoRa
|
||||
bitrate: 300, // ~300 bps
|
||||
bidirectional: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(&self, _dest: &TransportAddr, _data: &[u8]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&self) -> Result<TransportPacket> {
|
||||
bail!("mock")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_classification() {
|
||||
use crate::transport::TransportCapability;
|
||||
|
||||
// High bandwidth = Unconstrained
|
||||
assert_eq!(
|
||||
TransportCapability::from_metrics(10_000_000, 1500),
|
||||
TransportCapability::Unconstrained
|
||||
);
|
||||
|
||||
// Medium bandwidth = Medium
|
||||
assert_eq!(
|
||||
TransportCapability::from_metrics(50_000, 500),
|
||||
TransportCapability::Medium
|
||||
);
|
||||
|
||||
// LoRa-like = Constrained
|
||||
assert_eq!(
|
||||
TransportCapability::from_metrics(1200, 200),
|
||||
TransportCapability::Constrained
|
||||
);
|
||||
|
||||
// Very slow = SeverelyConstrained
|
||||
assert_eq!(
|
||||
TransportCapability::from_metrics(300, 51),
|
||||
TransportCapability::SeverelyConstrained
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_transport_selection() {
|
||||
let mut mgr = TransportManager::new();
|
||||
mgr.add(Box::new(MockLoRaTransport));
|
||||
mgr.add(Box::new(MockTransport::new("tcp")));
|
||||
|
||||
// TCP should be best (higher capability)
|
||||
let best = mgr.best_transport().expect("should have transport");
|
||||
assert_eq!(best.info().name, "tcp");
|
||||
assert!(mgr.supports_mls());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_crypto_based_on_transports() {
|
||||
use crate::transport::CryptoMode;
|
||||
|
||||
// With TCP available → MLS Hybrid
|
||||
let mut mgr = TransportManager::new();
|
||||
mgr.add(Box::new(MockTransport::new("tcp")));
|
||||
assert_eq!(mgr.recommended_crypto(), CryptoMode::MlsHybrid);
|
||||
|
||||
// With only LoRa → MLS-Lite unsigned
|
||||
let mut mgr_lora = TransportManager::new();
|
||||
mgr_lora.add(Box::new(MockLoRaTransport));
|
||||
assert_eq!(mgr_lora.recommended_crypto(), CryptoMode::MlsLiteUnsigned);
|
||||
|
||||
// Empty → default to MLS-Lite unsigned
|
||||
let empty = TransportManager::new();
|
||||
assert_eq!(empty.recommended_crypto(), CryptoMode::MlsLiteUnsigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_for_size_prefers_fitting() {
|
||||
let mut mgr = TransportManager::new();
|
||||
mgr.add(Box::new(MockLoRaTransport)); // MTU 51
|
||||
mgr.add(Box::new(MockTransport::new("tcp"))); // MTU 1500
|
||||
|
||||
// Small data should prefer TCP (fits and higher capability)
|
||||
let small = mgr.select_for_size(100).expect("transport");
|
||||
assert_eq!(small.info().name, "tcp");
|
||||
|
||||
// Data larger than LoRa MTU but smaller than TCP should use TCP
|
||||
let medium = mgr.select_for_size(500).expect("transport");
|
||||
assert_eq!(medium.info().name, "tcp");
|
||||
|
||||
// Huge data still uses TCP (highest capability)
|
||||
let huge = mgr.select_for_size(10000).expect("transport");
|
||||
assert_eq!(huge.info().name, "tcp");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user