//! 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)); } }