From 7be7287ba2b7c6b32c2966ba8b16647f84ad7b26 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 1 Apr 2026 09:00:57 +0200 Subject: [PATCH] feat(mesh): add MLS-Lite to full MLS upgrade path crypto_negotiation module enables transitioning between crypto modes: GroupCryptoState tracks current mode: - MlsLite (signed/unsigned) - FullMls (classical/hybrid) - Upgrading (transition state) MlsLiteBootstrap derives MLS-Lite keys from MLS epoch secret: - Enables fallback to MLS-Lite over constrained links - Same group can use full MLS over WiFi, MLS-Lite over LoRa Upgrade protocol: 1. Member sends KeyPackage over fast link 2. Creator creates MLS Welcome 3. Group transitions to full MLS 4. Optionally maintains MLS-Lite fallback for constrained links --- .../quicprochat-p2p/src/crypto_negotiation.rs | 337 ++++++++++++++++++ crates/quicprochat-p2p/src/lib.rs | 1 + 2 files changed, 338 insertions(+) create mode 100644 crates/quicprochat-p2p/src/crypto_negotiation.rs diff --git a/crates/quicprochat-p2p/src/crypto_negotiation.rs b/crates/quicprochat-p2p/src/crypto_negotiation.rs new file mode 100644 index 0000000..cffba8b --- /dev/null +++ b/crates/quicprochat-p2p/src/crypto_negotiation.rs @@ -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, + 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, + }, +} + +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, + /// 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, 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::::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, +} + +/// 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, + /// MLS Welcome message for the requesting member. + pub mls_welcome: Vec, + /// 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)); + } +} diff --git a/crates/quicprochat-p2p/src/lib.rs b/crates/quicprochat-p2p/src/lib.rs index 764881d..0380839 100644 --- a/crates/quicprochat-p2p/src/lib.rs +++ b/crates/quicprochat-p2p/src/lib.rs @@ -15,6 +15,7 @@ pub mod address; pub mod announce; pub mod announce_protocol; +pub mod crypto_negotiation; pub mod fapp; pub mod fapp_router; pub mod broadcast;