feat: upgrade OpenMLS 0.5 → 0.8 for security patches and GREASE support
Migrates all MLS code in quicprochat-core from OpenMLS 0.5 to 0.8: - StorageProvider replaces OpenMlsKeyStore (keystore.rs full rewrite) - HybridCryptoProvider updated for new OpenMlsProvider trait - Group operations updated for new API signatures - MLS state persistence via MemoryStorage serialization - tls_codec 0.3 → 0.4, openmls_traits/rust_crypto 0.2 → 0.5
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
//! # Ratchet tree
|
||||
//!
|
||||
//! `use_ratchet_tree_extension = true` so that the ratchet tree is embedded
|
||||
//! in Welcome messages. `new_from_welcome` is called with `ratchet_tree = None`;
|
||||
//! in Welcome messages. `new_from_welcome` is called without a ratchet_tree;
|
||||
//! openmls extracts the tree from the Welcome's `GroupInfo` extension.
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
@@ -37,12 +37,13 @@ use std::{path::Path, sync::Arc};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use openmls::prelude::{
|
||||
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, GroupId, KeyPackage,
|
||||
KeyPackageIn, MlsGroup, MlsGroupConfig, MlsMessageInBody, MlsMessageOut,
|
||||
ProcessedMessageContent, ProtocolMessage, ProtocolVersion, TlsDeserializeTrait,
|
||||
TlsSerializeTrait,
|
||||
BasicCredential, Ciphersuite, Credential, CredentialWithKey, GroupId, KeyPackage,
|
||||
KeyPackageIn, LeafNodeParameters, MlsGroup, MlsGroupCreateConfig, MlsGroupJoinConfig,
|
||||
MlsMessageBodyIn, MlsMessageOut, ProcessedMessageContent, ProtocolMessage,
|
||||
ProtocolVersion, StagedWelcome,
|
||||
};
|
||||
use openmls_traits::OpenMlsCryptoProvider;
|
||||
use openmls_traits::OpenMlsProvider;
|
||||
use tls_codec::{Deserialize as TlsDeserializeTrait, Serialize as TlsSerializeTrait};
|
||||
|
||||
use crate::{
|
||||
error::CoreError,
|
||||
@@ -102,8 +103,10 @@ pub struct GroupMember {
|
||||
identity: Arc<IdentityKeypair>,
|
||||
/// Active MLS group, if any.
|
||||
group: Option<MlsGroup>,
|
||||
/// Shared group configuration (wire format, ratchet tree extension, etc.).
|
||||
config: MlsGroupConfig,
|
||||
/// Shared group creation configuration (wire format, ratchet tree extension, etc.).
|
||||
create_config: MlsGroupCreateConfig,
|
||||
/// Shared group join configuration (wire format, ratchet tree extension, etc.).
|
||||
join_config: MlsGroupJoinConfig,
|
||||
/// Whether this member uses hybrid (X25519 + ML-KEM-768) HPKE keys.
|
||||
hybrid: bool,
|
||||
}
|
||||
@@ -139,7 +142,11 @@ impl GroupMember {
|
||||
group: Option<MlsGroup>,
|
||||
hybrid: bool,
|
||||
) -> Self {
|
||||
let config = MlsGroupConfig::builder()
|
||||
let create_config = MlsGroupCreateConfig::builder()
|
||||
.use_ratchet_tree_extension(true)
|
||||
.build();
|
||||
|
||||
let join_config = MlsGroupJoinConfig::builder()
|
||||
.use_ratchet_tree_extension(true)
|
||||
.build();
|
||||
|
||||
@@ -153,7 +160,8 @@ impl GroupMember {
|
||||
backend,
|
||||
identity,
|
||||
group,
|
||||
config,
|
||||
create_config,
|
||||
join_config,
|
||||
hybrid,
|
||||
}
|
||||
}
|
||||
@@ -175,18 +183,19 @@ impl GroupMember {
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage.
|
||||
pub fn generate_key_package(&mut self) -> Result<Vec<u8>, CoreError> {
|
||||
let credential_with_key = self.make_credential_with_key()?;
|
||||
let credential_with_key = self.make_credential_with_key();
|
||||
|
||||
let key_package = KeyPackage::builder()
|
||||
let key_package_bundle = KeyPackage::builder()
|
||||
.build(
|
||||
CryptoConfig::with_default_version(CIPHERSUITE),
|
||||
CIPHERSUITE,
|
||||
&self.backend,
|
||||
self.identity.as_ref(),
|
||||
credential_with_key,
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
|
||||
|
||||
key_package
|
||||
key_package_bundle
|
||||
.key_package()
|
||||
.tls_serialize_detached()
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))
|
||||
}
|
||||
@@ -205,13 +214,13 @@ impl GroupMember {
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if the group already exists or openmls fails.
|
||||
pub fn create_group(&mut self, group_id: &[u8]) -> Result<(), CoreError> {
|
||||
let credential_with_key = self.make_credential_with_key()?;
|
||||
let credential_with_key = self.make_credential_with_key();
|
||||
let mls_id = GroupId::from_slice(group_id);
|
||||
|
||||
let group = MlsGroup::new_with_group_id(
|
||||
&self.backend,
|
||||
self.identity.as_ref(),
|
||||
&self.config,
|
||||
&self.create_config,
|
||||
mls_id,
|
||||
credential_with_key,
|
||||
)
|
||||
@@ -303,7 +312,7 @@ impl GroupMember {
|
||||
|
||||
let leaf_index = group
|
||||
.members()
|
||||
.find(|m| m.credential.identity() == member_identity)
|
||||
.find(|m| m.credential.serialized_content() == member_identity)
|
||||
.map(|m| m.index)
|
||||
.ok_or_else(|| CoreError::Mls("member not found in group".into()))?;
|
||||
|
||||
@@ -384,7 +393,11 @@ impl GroupMember {
|
||||
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
|
||||
|
||||
let (proposal_out, _ref) = group
|
||||
.propose_self_update(&self.backend, self.identity.as_ref(), None)
|
||||
.propose_self_update(
|
||||
&self.backend,
|
||||
self.identity.as_ref(),
|
||||
LeafNodeParameters::default(),
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("propose_self_update: {e:?}")))?;
|
||||
|
||||
proposal_out
|
||||
@@ -396,7 +409,7 @@ impl GroupMember {
|
||||
pub fn has_pending_proposals(&self) -> bool {
|
||||
self.group
|
||||
.as_ref()
|
||||
.map(|g| g.pending_proposals().next().is_some())
|
||||
.map(|g| g.has_pending_proposals())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -417,16 +430,22 @@ impl GroupMember {
|
||||
let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut welcome_bytes)
|
||||
.map_err(|e| CoreError::Mls(format!("Welcome deserialise: {e:?}")))?;
|
||||
|
||||
// into_welcome() is feature-gated in openmls 0.5; extract() is public.
|
||||
let welcome = match msg_in.extract() {
|
||||
MlsMessageInBody::Welcome(w) => w,
|
||||
MlsMessageBodyIn::Welcome(w) => w,
|
||||
_ => return Err(CoreError::Mls("expected a Welcome message".into())),
|
||||
};
|
||||
|
||||
// ratchet_tree = None because use_ratchet_tree_extension = true embeds
|
||||
// the tree inside the Welcome's GroupInfo extension.
|
||||
let group = MlsGroup::new_from_welcome(&self.backend, &self.config, welcome, None)
|
||||
.map_err(|e| CoreError::Mls(format!("new_from_welcome: {e:?}")))?;
|
||||
let staged = StagedWelcome::new_from_welcome(
|
||||
&self.backend,
|
||||
&self.join_config,
|
||||
welcome,
|
||||
None, // ratchet tree extracted from the Welcome's GroupInfo extension
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("new_from_welcome: {e:?}")))?;
|
||||
|
||||
let group = staged
|
||||
.into_group(&self.backend)
|
||||
.map_err(|e| CoreError::Mls(format!("into_group: {e:?}")))?;
|
||||
|
||||
self.group = Some(group);
|
||||
Ok(())
|
||||
@@ -508,10 +527,9 @@ impl GroupMember {
|
||||
let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes)
|
||||
.map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?;
|
||||
|
||||
// into_protocol_message() is feature-gated; extract() + manual construction is not.
|
||||
let protocol_message = match msg_in.extract() {
|
||||
MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m),
|
||||
MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m),
|
||||
let protocol_message: ProtocolMessage = match msg_in.extract() {
|
||||
MlsMessageBodyIn::PrivateMessage(m) => m.into(),
|
||||
MlsMessageBodyIn::PublicMessage(m) => m.into(),
|
||||
_ => return Err(CoreError::Mls("not a protocol message".into())),
|
||||
};
|
||||
|
||||
@@ -519,7 +537,7 @@ impl GroupMember {
|
||||
.process_message(&self.backend, protocol_message)
|
||||
.map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?;
|
||||
|
||||
let sender_identity = processed.credential().identity().to_vec();
|
||||
let sender_identity = processed.credential().serialized_content().to_vec();
|
||||
|
||||
match processed.into_content() {
|
||||
ProcessedMessageContent::ApplicationMessage(app) => {
|
||||
@@ -545,11 +563,15 @@ impl GroupMember {
|
||||
}
|
||||
// Proposals are stored for a later Commit; nothing to return yet.
|
||||
ProcessedMessageContent::ProposalMessage(proposal) => {
|
||||
group.store_pending_proposal(*proposal);
|
||||
group
|
||||
.store_pending_proposal(self.backend.storage(), *proposal)
|
||||
.map_err(|e| CoreError::Mls(format!("store_pending_proposal: {e:?}")))?;
|
||||
Ok((sender_identity, ReceivedMessage::StateChanged))
|
||||
}
|
||||
ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => {
|
||||
group.store_pending_proposal(*proposal);
|
||||
group
|
||||
.store_pending_proposal(self.backend.storage(), *proposal)
|
||||
.map_err(|e| CoreError::Mls(format!("store_pending_proposal: {e:?}")))?;
|
||||
Ok((sender_identity, ReceivedMessage::StateChanged))
|
||||
}
|
||||
}
|
||||
@@ -597,6 +619,69 @@ impl GroupMember {
|
||||
self.group.as_ref()
|
||||
}
|
||||
|
||||
/// Serialize the MLS group state (via the backing `StorageProvider`).
|
||||
///
|
||||
/// In openmls 0.8 the `MlsGroup` is no longer `Serialize`; its state is
|
||||
/// held inside the `StorageProvider`. This method serializes the full
|
||||
/// provider storage to bytes, which can later be restored with
|
||||
/// [`new_from_storage_bytes`].
|
||||
///
|
||||
/// Returns `None` if no active group exists.
|
||||
///
|
||||
/// [`new_from_storage_bytes`]: Self::new_from_storage_bytes
|
||||
pub fn serialize_mls_state(&self) -> Result<Option<Vec<u8>>, CoreError> {
|
||||
if self.group.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = self
|
||||
.backend
|
||||
.storage()
|
||||
.to_bytes()
|
||||
.map_err(|e| CoreError::Mls(format!("serialize storage: {e}")))?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
/// Create a `GroupMember` from previously serialized storage bytes.
|
||||
///
|
||||
/// Reconstructs the `DiskKeyStore` from the blob, then loads the
|
||||
/// `MlsGroup` from the storage provider using the given `group_id`.
|
||||
pub fn new_from_storage_bytes(
|
||||
identity: Arc<IdentityKeypair>,
|
||||
storage_bytes: &[u8],
|
||||
group_id: &[u8],
|
||||
hybrid: bool,
|
||||
) -> Result<Self, CoreError> {
|
||||
let key_store = DiskKeyStore::from_bytes(storage_bytes)
|
||||
.map_err(|e| CoreError::Mls(format!("deserialize storage: {e}")))?;
|
||||
|
||||
let create_config = MlsGroupCreateConfig::builder()
|
||||
.use_ratchet_tree_extension(true)
|
||||
.build();
|
||||
|
||||
let join_config = MlsGroupJoinConfig::builder()
|
||||
.use_ratchet_tree_extension(true)
|
||||
.build();
|
||||
|
||||
let backend = if hybrid {
|
||||
HybridCryptoProvider::new_hybrid(key_store)
|
||||
} else {
|
||||
HybridCryptoProvider::new_classical(key_store)
|
||||
};
|
||||
|
||||
let mls_group_id = GroupId::from_slice(group_id);
|
||||
let group = MlsGroup::load(backend.storage(), &mls_group_id)
|
||||
.map_err(|e| CoreError::Mls(format!("load group from storage: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
backend,
|
||||
identity,
|
||||
group,
|
||||
create_config,
|
||||
join_config,
|
||||
hybrid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the identity (credential) bytes of all current group members.
|
||||
///
|
||||
/// Each entry is the raw credential payload (Ed25519 public key bytes)
|
||||
@@ -608,23 +693,20 @@ impl GroupMember {
|
||||
};
|
||||
group
|
||||
.members()
|
||||
.map(|m| m.credential.identity().to_vec())
|
||||
.map(|m| m.credential.serialized_content().to_vec())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
fn make_credential_with_key(&self) -> Result<CredentialWithKey, CoreError> {
|
||||
let credential = Credential::new(
|
||||
self.identity.public_key_bytes().to_vec(),
|
||||
CredentialType::Basic,
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
|
||||
fn make_credential_with_key(&self) -> CredentialWithKey {
|
||||
let credential: Credential =
|
||||
BasicCredential::new(self.identity.public_key_bytes().to_vec()).into();
|
||||
|
||||
Ok(CredentialWithKey {
|
||||
CredentialWithKey {
|
||||
credential,
|
||||
signature_key: self.identity.public_key_bytes().to_vec().into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,11 +840,6 @@ mod tests {
|
||||
let (_commit_a, welcome_a) = creator.add_member(&a_kp).expect("add A");
|
||||
a.join_group(&welcome_a).expect("A join");
|
||||
|
||||
// A must process the commit that added them (it's a StateChanged for A since
|
||||
// the commit itself is what brought them in — but actually A joined via Welcome,
|
||||
// so A doesn't process the add-commit). The creator already merged the pending
|
||||
// commit in add_member, so creator is at epoch 2.
|
||||
|
||||
// Add B — at this point creator is at epoch 2 (after adding A).
|
||||
let (commit_b, welcome_b) = creator.add_member(&b_kp).expect("add B");
|
||||
b.join_group(&welcome_b).expect("B join");
|
||||
@@ -958,7 +1035,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 10 messages alternating Alice→Bob and Bob→Alice all decrypt successfully.
|
||||
/// 10 messages alternating Alice->Bob and Bob->Alice all decrypt successfully.
|
||||
/// Verifies that epoch state stays in sync across multiple application messages.
|
||||
#[test]
|
||||
fn multi_message_roundtrip_epoch_stays_in_sync() {
|
||||
|
||||
Reference in New Issue
Block a user