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
This commit is contained in:
2026-04-01 09:00:57 +02:00
parent 3c6eebdb00
commit 7be7287ba2
2 changed files with 338 additions and 0 deletions

View 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));
}
}

View File

@@ -15,6 +15,7 @@
pub mod address; pub mod address;
pub mod announce; pub mod announce;
pub mod announce_protocol; pub mod announce_protocol;
pub mod crypto_negotiation;
pub mod fapp; pub mod fapp;
pub mod fapp_router; pub mod fapp_router;
pub mod broadcast; pub mod broadcast;