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:
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user