| // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| // Copyright by contributors to this project. |
| // SPDX-License-Identifier: (Apache-2.0 OR MIT) |
| |
| use alloc::vec; |
| use alloc::vec::Vec; |
| use core::fmt::{self, Debug}; |
| use mls_rs_codec::{MlsDecode, MlsEncode, MlsSize}; |
| use mls_rs_core::error::IntoAnyError; |
| use mls_rs_core::secret::Secret; |
| use mls_rs_core::time::MlsTime; |
| |
| use crate::cipher_suite::CipherSuite; |
| use crate::client::MlsError; |
| use crate::client_config::ClientConfig; |
| use crate::crypto::{HpkeCiphertext, SignatureSecretKey}; |
| use crate::extension::RatchetTreeExt; |
| use crate::identity::SigningIdentity; |
| use crate::key_package::{KeyPackage, KeyPackageRef}; |
| use crate::protocol_version::ProtocolVersion; |
| use crate::psk::secret::PskSecret; |
| use crate::psk::PreSharedKeyID; |
| use crate::signer::Signable; |
| use crate::tree_kem::hpke_encryption::HpkeEncryptable; |
| use crate::tree_kem::kem::TreeKem; |
| use crate::tree_kem::node::LeafIndex; |
| use crate::tree_kem::path_secret::PathSecret; |
| pub use crate::tree_kem::Capabilities; |
| use crate::tree_kem::{ |
| leaf_node::LeafNode, |
| leaf_node_validator::{LeafNodeValidator, ValidationContext}, |
| }; |
| use crate::tree_kem::{math as tree_math, ValidatedUpdatePath}; |
| use crate::tree_kem::{TreeKemPrivate, TreeKemPublic}; |
| use crate::{CipherSuiteProvider, CryptoProvider}; |
| |
| #[cfg(feature = "by_ref_proposal")] |
| use crate::crypto::{HpkePublicKey, HpkeSecretKey}; |
| |
| use crate::extension::ExternalPubExt; |
| |
| #[cfg(feature = "private_message")] |
| use self::mls_rules::{EncryptionOptions, MlsRules}; |
| |
| #[cfg(feature = "psk")] |
| pub use self::resumption::ReinitClient; |
| |
| #[cfg(feature = "psk")] |
| use crate::psk::{ |
| resolver::PskResolver, secret::PskSecretInput, ExternalPskId, JustPreSharedKeyID, PskGroupId, |
| ResumptionPSKUsage, ResumptionPsk, |
| }; |
| |
| #[cfg(all(feature = "std", feature = "by_ref_proposal"))] |
| use std::collections::HashMap; |
| |
| #[cfg(feature = "private_message")] |
| use ciphertext_processor::*; |
| |
| use confirmation_tag::*; |
| use framing::*; |
| use key_schedule::*; |
| use membership_tag::*; |
| use message_signature::*; |
| use message_verifier::*; |
| use proposal::*; |
| #[cfg(feature = "by_ref_proposal")] |
| use proposal_cache::*; |
| use state::*; |
| use transcript_hash::*; |
| |
| #[cfg(test)] |
| pub(crate) use self::commit::test_utils::CommitModifiers; |
| |
| #[cfg(all(test, feature = "private_message"))] |
| pub use self::framing::PrivateMessage; |
| |
| #[cfg(feature = "psk")] |
| use self::proposal_filter::ProposalInfo; |
| |
| #[cfg(any(feature = "secret_tree_access", feature = "private_message"))] |
| use secret_tree::*; |
| |
| #[cfg(feature = "prior_epoch")] |
| use self::epoch::PriorEpoch; |
| |
| use self::epoch::EpochSecrets; |
| pub use self::message_processor::{ |
| ApplicationMessageDescription, CommitMessageDescription, ProposalMessageDescription, |
| ProposalSender, ReceivedMessage, StateUpdate, |
| }; |
| use self::message_processor::{EventOrContent, MessageProcessor, ProvisionalState}; |
| #[cfg(feature = "by_ref_proposal")] |
| use self::proposal_ref::ProposalRef; |
| use self::state_repo::GroupStateRepository; |
| pub use group_info::GroupInfo; |
| |
| pub use self::framing::{ContentType, Sender}; |
| pub use commit::*; |
| pub use context::GroupContext; |
| pub use roster::*; |
| |
| pub(crate) use transcript_hash::ConfirmedTranscriptHash; |
| pub(crate) use util::*; |
| |
| #[cfg(all(feature = "by_ref_proposal", feature = "external_client"))] |
| pub use self::message_processor::CachedProposal; |
| |
| #[cfg(feature = "private_message")] |
| mod ciphertext_processor; |
| |
| mod commit; |
| pub(crate) mod confirmation_tag; |
| mod context; |
| pub(crate) mod epoch; |
| pub(crate) mod framing; |
| mod group_info; |
| pub(crate) mod key_schedule; |
| mod membership_tag; |
| pub(crate) mod message_processor; |
| pub(crate) mod message_signature; |
| pub(crate) mod message_verifier; |
| pub mod mls_rules; |
| #[cfg(feature = "private_message")] |
| pub(crate) mod padding; |
| /// Proposals to evolve a MLS [`Group`] |
| pub mod proposal; |
| mod proposal_cache; |
| pub(crate) mod proposal_filter; |
| #[cfg(feature = "by_ref_proposal")] |
| pub(crate) mod proposal_ref; |
| #[cfg(feature = "psk")] |
| mod resumption; |
| mod roster; |
| pub(crate) mod snapshot; |
| pub(crate) mod state; |
| |
| #[cfg(feature = "prior_epoch")] |
| pub(crate) mod state_repo; |
| #[cfg(not(feature = "prior_epoch"))] |
| pub(crate) mod state_repo_light; |
| #[cfg(not(feature = "prior_epoch"))] |
| pub(crate) use state_repo_light as state_repo; |
| |
| pub(crate) mod transcript_hash; |
| mod util; |
| |
| /// External commit building. |
| pub mod external_commit; |
| |
| #[cfg(any(feature = "secret_tree_access", feature = "private_message"))] |
| pub(crate) mod secret_tree; |
| |
| #[cfg(any(feature = "secret_tree_access", feature = "private_message"))] |
| pub use secret_tree::MessageKeyData as MessageKey; |
| |
| #[cfg(all(test, feature = "rfc_compliant"))] |
| mod interop_test_vectors; |
| |
| mod exported_tree; |
| |
| pub use exported_tree::ExportedTree; |
| |
| #[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode, MlsDecode)] |
| struct GroupSecrets { |
| joiner_secret: JoinerSecret, |
| path_secret: Option<PathSecret>, |
| psks: Vec<PreSharedKeyID>, |
| } |
| |
| impl HpkeEncryptable for GroupSecrets { |
| const ENCRYPT_LABEL: &'static str = "Welcome"; |
| |
| fn from_bytes(bytes: Vec<u8>) -> Result<Self, MlsError> { |
| Self::mls_decode(&mut bytes.as_slice()).map_err(Into::into) |
| } |
| |
| fn get_bytes(&self) -> Result<Vec<u8>, MlsError> { |
| self.mls_encode_to_vec().map_err(Into::into) |
| } |
| } |
| |
| #[derive(Clone, Debug, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)] |
| #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] |
| pub(crate) struct EncryptedGroupSecrets { |
| pub new_member: KeyPackageRef, |
| pub encrypted_group_secrets: HpkeCiphertext, |
| } |
| |
| #[derive(Clone, Eq, PartialEq, MlsSize, MlsEncode, MlsDecode)] |
| #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] |
| pub(crate) struct Welcome { |
| pub cipher_suite: CipherSuite, |
| pub secrets: Vec<EncryptedGroupSecrets>, |
| #[mls_codec(with = "mls_rs_codec::byte_vec")] |
| pub encrypted_group_info: Vec<u8>, |
| } |
| |
| impl Debug for Welcome { |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| f.debug_struct("Welcome") |
| .field("cipher_suite", &self.cipher_suite) |
| .field("secrets", &self.secrets) |
| .field( |
| "encrypted_group_info", |
| &mls_rs_core::debug::pretty_bytes(&self.encrypted_group_info), |
| ) |
| .finish() |
| } |
| } |
| |
| #[derive(Clone, Debug)] |
| #[cfg_attr( |
| all(feature = "ffi", not(test)), |
| safer_ffi_gen::ffi_type(clone, opaque) |
| )] |
| #[non_exhaustive] |
| /// Information provided to new members upon joining a group. |
| pub struct NewMemberInfo { |
| /// Group info extensions found within the Welcome message used to join |
| /// the group. |
| pub group_info_extensions: ExtensionList, |
| } |
| |
| #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)] |
| impl NewMemberInfo { |
| pub(crate) fn new(group_info_extensions: ExtensionList) -> Self { |
| let mut new_member_info = Self { |
| group_info_extensions, |
| }; |
| |
| new_member_info.ungrease(); |
| |
| new_member_info |
| } |
| |
| /// Group info extensions found within the Welcome message used to join |
| /// the group. |
| #[cfg(feature = "ffi")] |
| pub fn group_info_extensions(&self) -> &ExtensionList { |
| &self.group_info_extensions |
| } |
| } |
| |
| /// An MLS end-to-end encrypted group. |
| /// |
| /// # Group Evolution |
| /// |
| /// MLS Groups are evolved via a propose-then-commit system. Each group state |
| /// produced by a commit is called an epoch and can produce and consume |
| /// application, proposal, and commit messages. A [commit](Group::commit) is used |
| /// to advance to the next epoch by applying existing proposals sent in |
| /// the current epoch by-reference along with an optional set of proposals |
| /// that are included by-value using a [`CommitBuilder`]. |
| #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::ffi_type(opaque))] |
| #[derive(Clone)] |
| pub struct Group<C> |
| where |
| C: ClientConfig, |
| { |
| config: C, |
| cipher_suite_provider: <C::CryptoProvider as CryptoProvider>::CipherSuiteProvider, |
| state_repo: GroupStateRepository<C::GroupStateStorage, C::KeyPackageRepository>, |
| pub(crate) state: GroupState, |
| epoch_secrets: EpochSecrets, |
| private_tree: TreeKemPrivate, |
| key_schedule: KeySchedule, |
| #[cfg(all(feature = "std", feature = "by_ref_proposal"))] |
| pending_updates: HashMap<HpkePublicKey, (HpkeSecretKey, Option<SignatureSecretKey>)>, // Hash of leaf node hpke public key to secret key |
| #[cfg(all(not(feature = "std"), feature = "by_ref_proposal"))] |
| pending_updates: Vec<(HpkePublicKey, (HpkeSecretKey, Option<SignatureSecretKey>))>, |
| pending_commit: Option<CommitGeneration>, |
| #[cfg(feature = "psk")] |
| previous_psk: Option<PskSecretInput>, |
| #[cfg(test)] |
| pub(crate) commit_modifiers: CommitModifiers, |
| pub(crate) signer: SignatureSecretKey, |
| } |
| |
| #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen)] |
| impl<C> Group<C> |
| where |
| C: ClientConfig + Clone, |
| { |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub(crate) async fn new( |
| config: C, |
| group_id: Option<Vec<u8>>, |
| cipher_suite: CipherSuite, |
| protocol_version: ProtocolVersion, |
| signing_identity: SigningIdentity, |
| group_context_extensions: ExtensionList, |
| signer: SignatureSecretKey, |
| ) -> Result<Self, MlsError> { |
| let cipher_suite_provider = cipher_suite_provider(config.crypto_provider(), cipher_suite)?; |
| |
| let (leaf_node, leaf_node_secret) = LeafNode::generate( |
| &cipher_suite_provider, |
| config.leaf_properties(), |
| signing_identity, |
| &signer, |
| config.lifetime(), |
| ) |
| .await?; |
| |
| let identity_provider = config.identity_provider(); |
| |
| let leaf_node_validator = LeafNodeValidator::new( |
| &cipher_suite_provider, |
| &identity_provider, |
| Some(&group_context_extensions), |
| ); |
| |
| leaf_node_validator |
| .check_if_valid(&leaf_node, ValidationContext::Add(None)) |
| .await?; |
| |
| let (mut public_tree, private_tree) = TreeKemPublic::derive( |
| leaf_node, |
| leaf_node_secret, |
| &config.identity_provider(), |
| &group_context_extensions, |
| ) |
| .await?; |
| |
| let tree_hash = public_tree.tree_hash(&cipher_suite_provider).await?; |
| |
| let group_id = group_id.map(Ok).unwrap_or_else(|| { |
| cipher_suite_provider |
| .random_bytes_vec(cipher_suite_provider.kdf_extract_size()) |
| .map_err(|e| MlsError::CryptoProviderError(e.into_any_error())) |
| })?; |
| |
| let context = GroupContext::new_group( |
| protocol_version, |
| cipher_suite, |
| group_id, |
| tree_hash, |
| group_context_extensions, |
| ); |
| |
| let state_repo = GroupStateRepository::new( |
| #[cfg(feature = "prior_epoch")] |
| context.group_id.clone(), |
| config.group_state_storage(), |
| config.key_package_repo(), |
| None, |
| )?; |
| |
| let key_schedule_result = KeySchedule::from_random_epoch_secret( |
| &cipher_suite_provider, |
| #[cfg(any(feature = "secret_tree_access", feature = "private_message"))] |
| public_tree.total_leaf_count(), |
| ) |
| .await?; |
| |
| let confirmation_tag = ConfirmationTag::create( |
| &key_schedule_result.confirmation_key, |
| &vec![].into(), |
| &cipher_suite_provider, |
| ) |
| .await?; |
| |
| let interim_hash = InterimTranscriptHash::create( |
| &cipher_suite_provider, |
| &vec![].into(), |
| &confirmation_tag, |
| ) |
| .await?; |
| |
| Ok(Self { |
| config, |
| state: GroupState::new(context, public_tree, interim_hash, confirmation_tag), |
| private_tree, |
| key_schedule: key_schedule_result.key_schedule, |
| #[cfg(feature = "by_ref_proposal")] |
| pending_updates: Default::default(), |
| pending_commit: None, |
| #[cfg(test)] |
| commit_modifiers: Default::default(), |
| epoch_secrets: key_schedule_result.epoch_secrets, |
| state_repo, |
| cipher_suite_provider, |
| #[cfg(feature = "psk")] |
| previous_psk: None, |
| signer, |
| }) |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub(crate) async fn join( |
| welcome: &MlsMessage, |
| tree_data: Option<ExportedTree<'_>>, |
| config: C, |
| signer: SignatureSecretKey, |
| ) -> Result<(Self, NewMemberInfo), MlsError> { |
| Self::from_welcome_message( |
| welcome, |
| tree_data, |
| config, |
| signer, |
| #[cfg(feature = "psk")] |
| None, |
| ) |
| .await |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn from_welcome_message( |
| welcome: &MlsMessage, |
| tree_data: Option<ExportedTree<'_>>, |
| config: C, |
| signer: SignatureSecretKey, |
| #[cfg(feature = "psk")] additional_psk: Option<PskSecretInput>, |
| ) -> Result<(Self, NewMemberInfo), MlsError> { |
| let protocol_version = welcome.version; |
| |
| if !config.version_supported(protocol_version) { |
| return Err(MlsError::UnsupportedProtocolVersion(protocol_version)); |
| } |
| |
| let MlsMessagePayload::Welcome(welcome) = &welcome.payload else { |
| return Err(MlsError::UnexpectedMessageType); |
| }; |
| |
| let cipher_suite_provider = |
| cipher_suite_provider(config.crypto_provider(), welcome.cipher_suite)?; |
| |
| let (encrypted_group_secrets, key_package_generation) = |
| find_key_package_generation(&config.key_package_repo(), &welcome.secrets).await?; |
| |
| let key_package_version = key_package_generation.key_package.version; |
| |
| if key_package_version != protocol_version { |
| return Err(MlsError::ProtocolVersionMismatch); |
| } |
| |
| // Decrypt the encrypted_group_secrets using HPKE with the algorithms indicated by the |
| // cipher suite and the HPKE private key corresponding to the GroupSecrets. If a |
| // PreSharedKeyID is part of the GroupSecrets and the client is not in possession of |
| // the corresponding PSK, return an error |
| let group_secrets = GroupSecrets::decrypt( |
| &cipher_suite_provider, |
| &key_package_generation.init_secret_key, |
| &key_package_generation.key_package.hpke_init_key, |
| &welcome.encrypted_group_info, |
| &encrypted_group_secrets.encrypted_group_secrets, |
| ) |
| .await?; |
| |
| #[cfg(feature = "psk")] |
| let psk_secret = if let Some(psk) = additional_psk { |
| let psk_id = group_secrets |
| .psks |
| .first() |
| .ok_or(MlsError::UnexpectedPskId)?; |
| |
| match &psk_id.key_id { |
| JustPreSharedKeyID::Resumption(r) if r.usage != ResumptionPSKUsage::Application => { |
| Ok(()) |
| } |
| _ => Err(MlsError::UnexpectedPskId), |
| }?; |
| |
| let mut psk = psk; |
| psk.id.psk_nonce = psk_id.psk_nonce.clone(); |
| PskSecret::calculate(&[psk], &cipher_suite_provider).await? |
| } else { |
| PskResolver::< |
| <C as ClientConfig>::GroupStateStorage, |
| <C as ClientConfig>::KeyPackageRepository, |
| <C as ClientConfig>::PskStore, |
| > { |
| group_context: None, |
| current_epoch: None, |
| prior_epochs: None, |
| psk_store: &config.secret_store(), |
| } |
| .resolve_to_secret(&group_secrets.psks, &cipher_suite_provider) |
| .await? |
| }; |
| |
| #[cfg(not(feature = "psk"))] |
| let psk_secret = PskSecret::new(&cipher_suite_provider); |
| |
| // From the joiner_secret in the decrypted GroupSecrets object and the PSKs specified in |
| // the GroupSecrets, derive the welcome_secret and using that the welcome_key and |
| // welcome_nonce. |
| let welcome_secret = WelcomeSecret::from_joiner_secret( |
| &cipher_suite_provider, |
| &group_secrets.joiner_secret, |
| &psk_secret, |
| ) |
| .await?; |
| |
| // Use the key and nonce to decrypt the encrypted_group_info field. |
| let decrypted_group_info = welcome_secret |
| .decrypt(&welcome.encrypted_group_info) |
| .await?; |
| |
| let group_info = GroupInfo::mls_decode(&mut &**decrypted_group_info)?; |
| |
| let public_tree = validate_group_info_joiner( |
| protocol_version, |
| &group_info, |
| tree_data, |
| &config.identity_provider(), |
| &cipher_suite_provider, |
| ) |
| .await?; |
| |
| // Identify a leaf in the tree array (any even-numbered node) whose leaf_node is identical |
| // to the leaf_node field of the KeyPackage. If no such field exists, return an error. Let |
| // index represent the index of this node among the leaves in the tree, namely the index of |
| // the node in the tree array divided by two. |
| let self_index = public_tree |
| .find_leaf_node(&key_package_generation.key_package.leaf_node) |
| .ok_or(MlsError::WelcomeKeyPackageNotFound)?; |
| |
| let used_key_package_ref = key_package_generation.reference; |
| |
| let mut private_tree = |
| TreeKemPrivate::new_self_leaf(self_index, key_package_generation.leaf_node_secret_key); |
| |
| // If the path_secret value is set in the GroupSecrets object |
| if let Some(path_secret) = group_secrets.path_secret { |
| private_tree |
| .update_secrets( |
| &cipher_suite_provider, |
| group_info.signer, |
| path_secret, |
| &public_tree, |
| ) |
| .await?; |
| } |
| |
| // Use the joiner_secret from the GroupSecrets object to generate the epoch secret and |
| // other derived secrets for the current epoch. |
| let key_schedule_result = KeySchedule::from_joiner( |
| &cipher_suite_provider, |
| &group_secrets.joiner_secret, |
| &group_info.group_context, |
| #[cfg(any(feature = "secret_tree_access", feature = "private_message"))] |
| public_tree.total_leaf_count(), |
| &psk_secret, |
| ) |
| .await?; |
| |
| // Verify the confirmation tag in the GroupInfo using the derived confirmation key and the |
| // confirmed_transcript_hash from the GroupInfo. |
| if !group_info |
| .confirmation_tag |
| .matches( |
| &key_schedule_result.confirmation_key, |
| &group_info.group_context.confirmed_transcript_hash, |
| &cipher_suite_provider, |
| ) |
| .await? |
| { |
| return Err(MlsError::InvalidConfirmationTag); |
| } |
| |
| Self::join_with( |
| config, |
| group_info, |
| public_tree, |
| key_schedule_result.key_schedule, |
| key_schedule_result.epoch_secrets, |
| private_tree, |
| Some(used_key_package_ref), |
| signer, |
| ) |
| .await |
| } |
| |
| #[allow(clippy::too_many_arguments)] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn join_with( |
| config: C, |
| group_info: GroupInfo, |
| public_tree: TreeKemPublic, |
| key_schedule: KeySchedule, |
| epoch_secrets: EpochSecrets, |
| private_tree: TreeKemPrivate, |
| used_key_package_ref: Option<KeyPackageRef>, |
| signer: SignatureSecretKey, |
| ) -> Result<(Self, NewMemberInfo), MlsError> { |
| let cs = group_info.group_context.cipher_suite; |
| |
| let cs = config |
| .crypto_provider() |
| .cipher_suite_provider(cs) |
| .ok_or(MlsError::UnsupportedCipherSuite(cs))?; |
| |
| // Use the confirmed transcript hash and confirmation tag to compute the interim transcript |
| // hash in the new state. |
| let interim_transcript_hash = InterimTranscriptHash::create( |
| &cs, |
| &group_info.group_context.confirmed_transcript_hash, |
| &group_info.confirmation_tag, |
| ) |
| .await?; |
| |
| let state_repo = GroupStateRepository::new( |
| #[cfg(feature = "prior_epoch")] |
| group_info.group_context.group_id.clone(), |
| config.group_state_storage(), |
| config.key_package_repo(), |
| used_key_package_ref, |
| )?; |
| |
| let group = Group { |
| config, |
| state: GroupState::new( |
| group_info.group_context, |
| public_tree, |
| interim_transcript_hash, |
| group_info.confirmation_tag, |
| ), |
| private_tree, |
| key_schedule, |
| #[cfg(feature = "by_ref_proposal")] |
| pending_updates: Default::default(), |
| pending_commit: None, |
| #[cfg(test)] |
| commit_modifiers: Default::default(), |
| epoch_secrets, |
| state_repo, |
| cipher_suite_provider: cs, |
| #[cfg(feature = "psk")] |
| previous_psk: None, |
| signer, |
| }; |
| |
| Ok((group, NewMemberInfo::new(group_info.extensions))) |
| } |
| |
| #[inline(always)] |
| pub(crate) fn current_epoch_tree(&self) -> &TreeKemPublic { |
| &self.state.public_tree |
| } |
| |
| /// The current epoch of the group. This value is incremented each |
| /// time a [`Group::commit`] message is processed. |
| #[inline(always)] |
| pub fn current_epoch(&self) -> u64 { |
| self.context().epoch |
| } |
| |
| /// Index within the group's state for the local group instance. |
| /// |
| /// This index corresponds to indexes in content descriptions within |
| /// [`ReceivedMessage`]. |
| #[inline(always)] |
| pub fn current_member_index(&self) -> u32 { |
| self.private_tree.self_index.0 |
| } |
| |
| fn current_user_leaf_node(&self) -> Result<&LeafNode, MlsError> { |
| self.current_epoch_tree() |
| .get_leaf_node(self.private_tree.self_index) |
| } |
| |
| /// Signing identity currently in use by the local group instance. |
| #[cfg_attr(all(feature = "ffi", not(test)), safer_ffi_gen::safer_ffi_gen_ignore)] |
| pub fn current_member_signing_identity(&self) -> Result<&SigningIdentity, MlsError> { |
| self.current_user_leaf_node().map(|ln| &ln.signing_identity) |
| } |
| |
| /// Member at a specific index in the group state. |
| /// |
| /// These indexes correspond to indexes in content descriptions within |
| /// [`ReceivedMessage`]. |
| pub fn member_at_index(&self, index: u32) -> Option<Member> { |
| let leaf_index = LeafIndex(index); |
| |
| self.current_epoch_tree() |
| .get_leaf_node(leaf_index) |
| .ok() |
| .map(|ln| member_from_leaf_node(ln, leaf_index)) |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn proposal_message( |
| &mut self, |
| proposal: Proposal, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let sender = Sender::Member(*self.private_tree.self_index); |
| |
| let auth_content = AuthenticatedContent::new_signed( |
| &self.cipher_suite_provider, |
| self.context(), |
| sender, |
| Content::Proposal(alloc::boxed::Box::new(proposal.clone())), |
| &self.signer, |
| #[cfg(feature = "private_message")] |
| self.encryption_options()?.control_wire_format(sender), |
| #[cfg(not(feature = "private_message"))] |
| WireFormat::PublicMessage, |
| authenticated_data, |
| ) |
| .await?; |
| |
| let proposal_ref = |
| ProposalRef::from_content(&self.cipher_suite_provider, &auth_content).await?; |
| |
| self.state |
| .proposals |
| .insert(proposal_ref, proposal, auth_content.content.sender); |
| |
| self.format_for_wire(auth_content).await |
| } |
| |
| /// Unique identifier for this group. |
| pub fn group_id(&self) -> &[u8] { |
| &self.context().group_id |
| } |
| |
| fn provisional_private_tree( |
| &self, |
| provisional_state: &ProvisionalState, |
| ) -> Result<(TreeKemPrivate, Option<SignatureSecretKey>), MlsError> { |
| let mut provisional_private_tree = self.private_tree.clone(); |
| let self_index = provisional_private_tree.self_index; |
| |
| // Remove secret keys for blanked nodes |
| let path = provisional_state |
| .public_tree |
| .nodes |
| .direct_copath(self_index); |
| |
| provisional_private_tree |
| .secret_keys |
| .resize(path.len() + 1, None); |
| |
| for (i, n) in path.iter().enumerate() { |
| if provisional_state.public_tree.nodes.is_blank(n.path)? { |
| provisional_private_tree.secret_keys[i + 1] = None; |
| } |
| } |
| |
| // Apply own update |
| let new_signer = None; |
| |
| #[cfg(feature = "by_ref_proposal")] |
| let mut new_signer = new_signer; |
| |
| #[cfg(feature = "by_ref_proposal")] |
| for p in &provisional_state.applied_proposals.updates { |
| if p.sender == Sender::Member(*self_index) { |
| let leaf_pk = &p.proposal.leaf_node.public_key; |
| |
| // Update the leaf in the private tree if this is our update |
| #[cfg(feature = "std")] |
| let new_leaf_sk_and_signer = self.pending_updates.get(leaf_pk); |
| |
| #[cfg(not(feature = "std"))] |
| let new_leaf_sk_and_signer = self |
| .pending_updates |
| .iter() |
| .find_map(|(pk, sk)| (pk == leaf_pk).then_some(sk)); |
| |
| let new_leaf_sk = new_leaf_sk_and_signer.map(|(sk, _)| sk.clone()); |
| new_signer = new_leaf_sk_and_signer.and_then(|(_, sk)| sk.clone()); |
| |
| provisional_private_tree |
| .update_leaf(new_leaf_sk.ok_or(MlsError::UpdateErrorNoSecretKey)?); |
| |
| break; |
| } |
| } |
| |
| Ok((provisional_private_tree, new_signer)) |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn encrypt_group_secrets( |
| &self, |
| key_package: &KeyPackage, |
| leaf_index: LeafIndex, |
| joiner_secret: &JoinerSecret, |
| path_secrets: Option<&Vec<Option<PathSecret>>>, |
| #[cfg(feature = "psk")] psks: Vec<PreSharedKeyID>, |
| encrypted_group_info: &[u8], |
| ) -> Result<EncryptedGroupSecrets, MlsError> { |
| let path_secret = path_secrets |
| .map(|secrets| { |
| secrets |
| .get( |
| tree_math::leaf_lca_level(*self.private_tree.self_index, *leaf_index) |
| as usize |
| - 1, |
| ) |
| .cloned() |
| .flatten() |
| .ok_or(MlsError::InvalidTreeKemPrivateKey) |
| }) |
| .transpose()?; |
| |
| #[cfg(not(feature = "psk"))] |
| let psks = Vec::new(); |
| |
| let group_secrets = GroupSecrets { |
| joiner_secret: joiner_secret.clone(), |
| path_secret, |
| psks, |
| }; |
| |
| let encrypted_group_secrets = group_secrets |
| .encrypt( |
| &self.cipher_suite_provider, |
| &key_package.hpke_init_key, |
| encrypted_group_info, |
| ) |
| .await?; |
| |
| Ok(EncryptedGroupSecrets { |
| new_member: key_package |
| .to_reference(&self.cipher_suite_provider) |
| .await?, |
| encrypted_group_secrets, |
| }) |
| } |
| |
| /// Create a proposal message that adds a new member to the group. |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_add( |
| &mut self, |
| key_package: MlsMessage, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let proposal = self.add_proposal(key_package)?; |
| self.proposal_message(proposal, authenticated_data).await |
| } |
| |
| fn add_proposal(&self, key_package: MlsMessage) -> Result<Proposal, MlsError> { |
| Ok(Proposal::Add(alloc::boxed::Box::new(AddProposal { |
| key_package: key_package |
| .into_key_package() |
| .ok_or(MlsError::UnexpectedMessageType)?, |
| }))) |
| } |
| |
| /// Create a proposal message that updates your own public keys. |
| /// |
| /// This proposal is useful for contributing additional forward secrecy |
| /// and post-compromise security to the group without having to perform |
| /// the necessary computation of a [`Group::commit`]. |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_update( |
| &mut self, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let proposal = self.update_proposal(None, None).await?; |
| self.proposal_message(proposal, authenticated_data).await |
| } |
| |
| /// Create a proposal message that updates your own public keys |
| /// as well as your credential. |
| /// |
| /// This proposal is useful for contributing additional forward secrecy |
| /// and post-compromise security to the group without having to perform |
| /// the necessary computation of a [`Group::commit`]. |
| /// |
| /// Identity updates are allowed by the group by default assuming that the |
| /// new identity provided is considered |
| /// [valid](crate::IdentityProvider::validate_member) |
| /// by and matches the output of the |
| /// [identity](crate::IdentityProvider) |
| /// function of the current |
| /// [`IdentityProvider`](crate::IdentityProvider). |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_update_with_identity( |
| &mut self, |
| signer: SignatureSecretKey, |
| signing_identity: SigningIdentity, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let proposal = self |
| .update_proposal(Some(signer), Some(signing_identity)) |
| .await?; |
| |
| self.proposal_message(proposal, authenticated_data).await |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn update_proposal( |
| &mut self, |
| signer: Option<SignatureSecretKey>, |
| signing_identity: Option<SigningIdentity>, |
| ) -> Result<Proposal, MlsError> { |
| // Grab a copy of the current node and update it to have new key material |
| let mut new_leaf_node = self.current_user_leaf_node()?.clone(); |
| |
| let secret_key = new_leaf_node |
| .update( |
| &self.cipher_suite_provider, |
| self.group_id(), |
| self.current_member_index(), |
| self.config.leaf_properties(), |
| signing_identity, |
| signer.as_ref().unwrap_or(&self.signer), |
| ) |
| .await?; |
| |
| // Store the secret key in the pending updates storage for later |
| #[cfg(feature = "std")] |
| self.pending_updates |
| .insert(new_leaf_node.public_key.clone(), (secret_key, signer)); |
| |
| #[cfg(not(feature = "std"))] |
| self.pending_updates |
| .push((new_leaf_node.public_key.clone(), (secret_key, signer))); |
| |
| Ok(Proposal::Update(UpdateProposal { |
| leaf_node: new_leaf_node, |
| })) |
| } |
| |
| /// Create a proposal message that removes an existing member from the |
| /// group. |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_remove( |
| &mut self, |
| index: u32, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let proposal = self.remove_proposal(index)?; |
| self.proposal_message(proposal, authenticated_data).await |
| } |
| |
| fn remove_proposal(&self, index: u32) -> Result<Proposal, MlsError> { |
| let leaf_index = LeafIndex(index); |
| |
| // Verify that this leaf is actually in the tree |
| self.current_epoch_tree().get_leaf_node(leaf_index)?; |
| |
| Ok(Proposal::Remove(RemoveProposal { |
| to_remove: leaf_index, |
| })) |
| } |
| |
| /// Create a proposal message that adds an external pre shared key to the group. |
| /// |
| /// Each group member will need to have the PSK associated with |
| /// [`ExternalPskId`](mls_rs_core::psk::ExternalPskId) installed within |
| /// the [`PreSharedKeyStorage`](mls_rs_core::psk::PreSharedKeyStorage) |
| /// in use by this group upon processing a [commit](Group::commit) that |
| /// contains this proposal. |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(all(feature = "by_ref_proposal", feature = "psk"))] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_external_psk( |
| &mut self, |
| psk: ExternalPskId, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let proposal = self.psk_proposal(JustPreSharedKeyID::External(psk))?; |
| self.proposal_message(proposal, authenticated_data).await |
| } |
| |
| #[cfg(feature = "psk")] |
| fn psk_proposal(&self, key_id: JustPreSharedKeyID) -> Result<Proposal, MlsError> { |
| Ok(Proposal::Psk(PreSharedKeyProposal { |
| psk: PreSharedKeyID::new(key_id, &self.cipher_suite_provider)?, |
| })) |
| } |
| |
| /// Create a proposal message that adds a pre shared key from a previous |
| /// epoch to the current group state. |
| /// |
| /// Each group member will need to have the secret state from `psk_epoch`. |
| /// In particular, the members who joined between `psk_epoch` and the |
| /// current epoch cannot process a commit containing this proposal. |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(all(feature = "by_ref_proposal", feature = "psk"))] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_resumption_psk( |
| &mut self, |
| psk_epoch: u64, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let key_id = ResumptionPsk { |
| psk_epoch, |
| usage: ResumptionPSKUsage::Application, |
| psk_group_id: PskGroupId(self.group_id().to_vec()), |
| }; |
| |
| let proposal = self.psk_proposal(JustPreSharedKeyID::Resumption(key_id))?; |
| self.proposal_message(proposal, authenticated_data).await |
| } |
| |
| /// Create a proposal message that requests for this group to be |
| /// reinitialized. |
| /// |
| /// Once a [`ReInitProposal`](proposal::ReInitProposal) |
| /// has been sent, another group member can complete reinitialization of |
| /// the group by calling [`Group::get_reinit_client`]. |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_reinit( |
| &mut self, |
| group_id: Option<Vec<u8>>, |
| version: ProtocolVersion, |
| cipher_suite: CipherSuite, |
| extensions: ExtensionList, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let proposal = self.reinit_proposal(group_id, version, cipher_suite, extensions)?; |
| self.proposal_message(proposal, authenticated_data).await |
| } |
| |
| fn reinit_proposal( |
| &self, |
| group_id: Option<Vec<u8>>, |
| version: ProtocolVersion, |
| cipher_suite: CipherSuite, |
| extensions: ExtensionList, |
| ) -> Result<Proposal, MlsError> { |
| let group_id = group_id.map(Ok).unwrap_or_else(|| { |
| self.cipher_suite_provider |
| .random_bytes_vec(self.cipher_suite_provider.kdf_extract_size()) |
| .map_err(|e| MlsError::CryptoProviderError(e.into_any_error())) |
| })?; |
| |
| Ok(Proposal::ReInit(ReInitProposal { |
| group_id, |
| version, |
| cipher_suite, |
| extensions, |
| })) |
| } |
| |
| /// Create a proposal message that sets extensions stored in the group |
| /// state. |
| /// |
| /// # Warning |
| /// |
| /// This function does not create a diff that will be applied to the |
| /// current set of extension that are in use. In order for an existing |
| /// extension to not be overwritten by this proposal, it must be included |
| /// in the new set of extensions being proposed. |
| /// |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_group_context_extensions( |
| &mut self, |
| extensions: ExtensionList, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| let proposal = self.group_context_extensions_proposal(extensions); |
| self.proposal_message(proposal, authenticated_data).await |
| } |
| |
| fn group_context_extensions_proposal(&self, extensions: ExtensionList) -> Proposal { |
| Proposal::GroupContextExtensions(extensions) |
| } |
| |
| /// Create a custom proposal message. |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(all(feature = "custom_proposal", feature = "by_ref_proposal"))] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn propose_custom( |
| &mut self, |
| proposal: CustomProposal, |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| self.proposal_message(Proposal::Custom(proposal), authenticated_data) |
| .await |
| } |
| |
| /// Delete all sent and received proposals cached for commit. |
| #[cfg(feature = "by_ref_proposal")] |
| pub fn clear_proposal_cache(&mut self) { |
| self.state.proposals.clear() |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub(crate) async fn format_for_wire( |
| &mut self, |
| content: AuthenticatedContent, |
| ) -> Result<MlsMessage, MlsError> { |
| #[cfg(feature = "private_message")] |
| let payload = if content.wire_format == WireFormat::PrivateMessage { |
| MlsMessagePayload::Cipher(self.create_ciphertext(content).await?) |
| } else { |
| MlsMessagePayload::Plain(self.create_plaintext(content).await?) |
| }; |
| #[cfg(not(feature = "private_message"))] |
| let payload = MlsMessagePayload::Plain(self.create_plaintext(content).await?); |
| |
| Ok(MlsMessage::new(self.protocol_version(), payload)) |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn create_plaintext( |
| &self, |
| auth_content: AuthenticatedContent, |
| ) -> Result<PublicMessage, MlsError> { |
| let membership_tag = if matches!(auth_content.content.sender, Sender::Member(_)) { |
| let tag = self |
| .key_schedule |
| .get_membership_tag(&auth_content, self.context(), &self.cipher_suite_provider) |
| .await?; |
| |
| Some(tag) |
| } else { |
| None |
| }; |
| |
| Ok(PublicMessage { |
| content: auth_content.content, |
| auth: auth_content.auth, |
| membership_tag, |
| }) |
| } |
| |
| #[cfg(feature = "private_message")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn create_ciphertext( |
| &mut self, |
| auth_content: AuthenticatedContent, |
| ) -> Result<PrivateMessage, MlsError> { |
| let padding_mode = self.encryption_options()?.padding_mode; |
| |
| let mut encryptor = CiphertextProcessor::new(self, self.cipher_suite_provider.clone()); |
| |
| encryptor.seal(auth_content, padding_mode).await |
| } |
| |
| /// Encrypt an application message using the current group state. |
| /// |
| /// `authenticated_data` will be sent unencrypted along with the contents |
| /// of the proposal message. |
| #[cfg(feature = "private_message")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn encrypt_application_message( |
| &mut self, |
| message: &[u8], |
| authenticated_data: Vec<u8>, |
| ) -> Result<MlsMessage, MlsError> { |
| // A group member that has observed one or more proposals within an epoch MUST send a Commit message |
| // before sending application data |
| #[cfg(feature = "by_ref_proposal")] |
| if !self.state.proposals.is_empty() { |
| return Err(MlsError::CommitRequired); |
| } |
| |
| let auth_content = AuthenticatedContent::new_signed( |
| &self.cipher_suite_provider, |
| self.context(), |
| Sender::Member(*self.private_tree.self_index), |
| Content::Application(message.to_vec().into()), |
| &self.signer, |
| WireFormat::PrivateMessage, |
| authenticated_data, |
| ) |
| .await?; |
| |
| self.format_for_wire(auth_content).await |
| } |
| |
| #[cfg(feature = "private_message")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn decrypt_incoming_ciphertext( |
| &mut self, |
| message: &PrivateMessage, |
| ) -> Result<AuthenticatedContent, MlsError> { |
| let epoch_id = message.epoch; |
| |
| let auth_content = if epoch_id == self.context().epoch { |
| let content = CiphertextProcessor::new(self, self.cipher_suite_provider.clone()) |
| .open(message) |
| .await?; |
| |
| verify_auth_content_signature( |
| &self.cipher_suite_provider, |
| SignaturePublicKeysContainer::RatchetTree(&self.state.public_tree), |
| self.context(), |
| &content, |
| #[cfg(feature = "by_ref_proposal")] |
| &[], |
| ) |
| .await?; |
| |
| Ok::<_, MlsError>(content) |
| } else { |
| #[cfg(feature = "prior_epoch")] |
| { |
| let epoch = self |
| .state_repo |
| .get_epoch_mut(epoch_id) |
| .await? |
| .ok_or(MlsError::EpochNotFound)?; |
| |
| let content = CiphertextProcessor::new(epoch, self.cipher_suite_provider.clone()) |
| .open(message) |
| .await?; |
| |
| verify_auth_content_signature( |
| &self.cipher_suite_provider, |
| SignaturePublicKeysContainer::List(&epoch.signature_public_keys), |
| &epoch.context, |
| &content, |
| #[cfg(feature = "by_ref_proposal")] |
| &[], |
| ) |
| .await?; |
| |
| Ok(content) |
| } |
| |
| #[cfg(not(feature = "prior_epoch"))] |
| Err(MlsError::EpochNotFound) |
| }?; |
| |
| Ok(auth_content) |
| } |
| |
| /// Apply a pending commit that was created by [`Group::commit`] or |
| /// [`CommitBuilder::build`]. |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn apply_pending_commit(&mut self) -> Result<CommitMessageDescription, MlsError> { |
| let pending_commit = self |
| .pending_commit |
| .clone() |
| .ok_or(MlsError::PendingCommitNotFound)?; |
| |
| self.process_commit(pending_commit.content, None).await |
| } |
| |
| /// Returns true if a commit has been created but not yet applied |
| /// with [`Group::apply_pending_commit`] or cleared with [`Group::clear_pending_commit`] |
| pub fn has_pending_commit(&self) -> bool { |
| self.pending_commit.is_some() |
| } |
| |
| /// Clear the currently pending commit. |
| /// |
| /// This function will automatically be called in the event that a |
| /// commit message is processed using [`Group::process_incoming_message`] |
| /// before [`Group::apply_pending_commit`] is called. |
| pub fn clear_pending_commit(&mut self) { |
| self.pending_commit = None |
| } |
| |
| /// Process an inbound message for this group. |
| /// |
| /// # Warning |
| /// |
| /// Changes to the group's state as a result of processing `message` will |
| /// not be persisted by the |
| /// [`GroupStateStorage`](crate::GroupStateStorage) |
| /// in use by this group until [`Group::write_to_storage`] is called. |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| #[inline(never)] |
| pub async fn process_incoming_message( |
| &mut self, |
| message: MlsMessage, |
| ) -> Result<ReceivedMessage, MlsError> { |
| if let Some(pending) = &self.pending_commit { |
| let message_hash = CommitHash::compute(&self.cipher_suite_provider, &message).await?; |
| |
| if message_hash == pending.commit_message_hash { |
| let message_description = self.apply_pending_commit().await?; |
| |
| return Ok(ReceivedMessage::Commit(message_description)); |
| } |
| } |
| |
| MessageProcessor::process_incoming_message( |
| self, |
| message, |
| #[cfg(feature = "by_ref_proposal")] |
| true, |
| ) |
| .await |
| } |
| |
| /// Process an inbound message for this group, providing additional context |
| /// with a message timestamp. |
| /// |
| /// Providing a timestamp is useful when the |
| /// [`IdentityProvider`](crate::IdentityProvider) |
| /// in use by the group can determine validity based on a timestamp. |
| /// For example, this allows for checking X.509 certificate expiration |
| /// at the time when `message` was received by a server rather than when |
| /// a specific client asynchronously received `message` |
| /// |
| /// # Warning |
| /// |
| /// Changes to the group's state as a result of processing `message` will |
| /// not be persisted by the |
| /// [`GroupStateStorage`](crate::GroupStateStorage) |
| /// in use by this group until [`Group::write_to_storage`] is called. |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn process_incoming_message_with_time( |
| &mut self, |
| message: MlsMessage, |
| time: MlsTime, |
| ) -> Result<ReceivedMessage, MlsError> { |
| MessageProcessor::process_incoming_message_with_time( |
| self, |
| message, |
| #[cfg(feature = "by_ref_proposal")] |
| true, |
| Some(time), |
| ) |
| .await |
| } |
| |
| /// Find a group member by |
| /// [identity](crate::IdentityProvider::identity) |
| /// |
| /// This function determines identity by calling the |
| /// [`IdentityProvider`](crate::IdentityProvider) |
| /// currently in use by the group. |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn member_with_identity(&self, identity: &[u8]) -> Result<Member, MlsError> { |
| let tree = &self.state.public_tree; |
| |
| #[cfg(feature = "tree_index")] |
| let index = tree.get_leaf_node_with_identity(identity); |
| |
| #[cfg(not(feature = "tree_index"))] |
| let index = tree |
| .get_leaf_node_with_identity( |
| identity, |
| &self.identity_provider(), |
| &self.state.context.extensions, |
| ) |
| .await?; |
| |
| let index = index.ok_or(MlsError::MemberNotFound)?; |
| let node = self.state.public_tree.get_leaf_node(index)?; |
| |
| Ok(member_from_leaf_node(node, index)) |
| } |
| |
| /// Create a group info message that can be used for external proposals and commits. |
| /// |
| /// The returned `GroupInfo` is suitable for one external commit for the current epoch. |
| /// If `with_tree_in_extension` is set to true, the returned `GroupInfo` contains the |
| /// ratchet tree and therefore contains all information needed to join the group. Otherwise, |
| /// the ratchet tree must be obtained separately, e.g. via |
| /// (ExternalClient::export_tree)[crate::external_client::ExternalGroup::export_tree]. |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn group_info_message_allowing_ext_commit( |
| &self, |
| with_tree_in_extension: bool, |
| ) -> Result<MlsMessage, MlsError> { |
| let mut extensions = ExtensionList::new(); |
| |
| extensions.set_from({ |
| self.key_schedule |
| .get_external_key_pair_ext(&self.cipher_suite_provider) |
| .await? |
| })?; |
| |
| self.group_info_message_internal(extensions, with_tree_in_extension) |
| .await |
| } |
| |
| /// Create a group info message that can be used for external proposals. |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn group_info_message( |
| &self, |
| with_tree_in_extension: bool, |
| ) -> Result<MlsMessage, MlsError> { |
| self.group_info_message_internal(ExtensionList::new(), with_tree_in_extension) |
| .await |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn group_info_message_internal( |
| &self, |
| mut initial_extensions: ExtensionList, |
| with_tree_in_extension: bool, |
| ) -> Result<MlsMessage, MlsError> { |
| if with_tree_in_extension { |
| initial_extensions.set_from(RatchetTreeExt { |
| tree_data: ExportedTree::new(self.state.public_tree.nodes.clone()), |
| })?; |
| } |
| |
| let mut info = GroupInfo { |
| group_context: self.context().clone(), |
| extensions: initial_extensions, |
| confirmation_tag: self.state.confirmation_tag.clone(), |
| signer: self.private_tree.self_index, |
| signature: Vec::new(), |
| }; |
| |
| info.grease(self.cipher_suite_provider())?; |
| |
| info.sign(&self.cipher_suite_provider, &self.signer, &()) |
| .await?; |
| |
| Ok(MlsMessage::new( |
| self.protocol_version(), |
| MlsMessagePayload::GroupInfo(info), |
| )) |
| } |
| |
| /// Get the current group context summarizing various information about the group. |
| #[inline(always)] |
| pub fn context(&self) -> &GroupContext { |
| &self.group_state().context |
| } |
| |
| /// Get the |
| /// [epoch_authenticator](https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#name-key-schedule) |
| /// of the current epoch. |
| pub fn epoch_authenticator(&self) -> Result<Secret, MlsError> { |
| Ok(self.key_schedule.authentication_secret.clone().into()) |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn export_secret( |
| &self, |
| label: &[u8], |
| context: &[u8], |
| len: usize, |
| ) -> Result<Secret, MlsError> { |
| self.key_schedule |
| .export_secret(label, context, len, &self.cipher_suite_provider) |
| .await |
| .map(Into::into) |
| } |
| |
| /// Export the current epoch's ratchet tree in serialized format. |
| /// |
| /// This function is used to provide the current group tree to new members |
| /// when the `ratchet_tree_extension` is not used according to [`MlsRules::commit_options`]. |
| pub fn export_tree(&self) -> ExportedTree<'_> { |
| ExportedTree::new_borrowed(&self.current_epoch_tree().nodes) |
| } |
| |
| /// Current version of the MLS protocol in use by this group. |
| pub fn protocol_version(&self) -> ProtocolVersion { |
| self.context().protocol_version |
| } |
| |
| /// Current cipher suite in use by this group. |
| pub fn cipher_suite(&self) -> CipherSuite { |
| self.context().cipher_suite |
| } |
| |
| /// Current roster |
| pub fn roster(&self) -> Roster<'_> { |
| self.group_state().public_tree.roster() |
| } |
| |
| /// Determines equality of two different groups internal states. |
| /// Useful for testing. |
| /// |
| pub fn equal_group_state(a: &Group<C>, b: &Group<C>) -> bool { |
| a.state == b.state && a.key_schedule == b.key_schedule && a.epoch_secrets == b.epoch_secrets |
| } |
| |
| #[cfg(feature = "psk")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn get_psk( |
| &self, |
| psks: &[ProposalInfo<PreSharedKeyProposal>], |
| ) -> Result<(PskSecret, Vec<PreSharedKeyID>), MlsError> { |
| if let Some(psk) = self.previous_psk.clone() { |
| // TODO consider throwing error if psks not empty |
| let psk_id = vec![psk.id.clone()]; |
| let psk = PskSecret::calculate(&[psk], self.cipher_suite_provider()).await?; |
| |
| Ok((psk, psk_id)) |
| } else { |
| let psks = psks |
| .iter() |
| .map(|psk| psk.proposal.psk.clone()) |
| .collect::<Vec<_>>(); |
| |
| let psk = PskResolver { |
| group_context: Some(self.context()), |
| current_epoch: Some(&self.epoch_secrets), |
| prior_epochs: Some(&self.state_repo), |
| psk_store: &self.config.secret_store(), |
| } |
| .resolve_to_secret(&psks, self.cipher_suite_provider()) |
| .await?; |
| |
| Ok((psk, psks)) |
| } |
| } |
| |
| #[cfg(feature = "private_message")] |
| pub(crate) fn encryption_options(&self) -> Result<EncryptionOptions, MlsError> { |
| self.config |
| .mls_rules() |
| .encryption_options(&self.roster(), self.group_context().extensions()) |
| .map_err(|e| MlsError::MlsRulesError(e.into_any_error())) |
| } |
| |
| #[cfg(not(feature = "psk"))] |
| fn get_psk(&self) -> PskSecret { |
| PskSecret::new(self.cipher_suite_provider()) |
| } |
| |
| #[cfg(feature = "secret_tree_access")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| #[inline(never)] |
| pub async fn next_encryption_key(&mut self) -> Result<MessageKey, MlsError> { |
| self.epoch_secrets |
| .secret_tree |
| .next_message_key( |
| &self.cipher_suite_provider, |
| crate::tree_kem::node::NodeIndex::from(self.private_tree.self_index), |
| KeyType::Application, |
| ) |
| .await |
| } |
| |
| #[cfg(feature = "secret_tree_access")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| pub async fn derive_decryption_key( |
| &mut self, |
| sender: u32, |
| generation: u32, |
| ) -> Result<MessageKey, MlsError> { |
| self.epoch_secrets |
| .secret_tree |
| .message_key_generation( |
| &self.cipher_suite_provider, |
| crate::tree_kem::node::NodeIndex::from(sender), |
| KeyType::Application, |
| generation, |
| ) |
| .await |
| } |
| } |
| |
| #[cfg(feature = "private_message")] |
| impl<C> GroupStateProvider for Group<C> |
| where |
| C: ClientConfig + Clone, |
| { |
| fn group_context(&self) -> &GroupContext { |
| self.context() |
| } |
| |
| fn self_index(&self) -> LeafIndex { |
| self.private_tree.self_index |
| } |
| |
| fn epoch_secrets_mut(&mut self) -> &mut EpochSecrets { |
| &mut self.epoch_secrets |
| } |
| |
| fn epoch_secrets(&self) -> &EpochSecrets { |
| &self.epoch_secrets |
| } |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| #[cfg_attr(all(target_arch = "wasm32", mls_build_async), maybe_async::must_be_async(?Send))] |
| #[cfg_attr( |
| all(not(target_arch = "wasm32"), mls_build_async), |
| maybe_async::must_be_async |
| )] |
| impl<C> MessageProcessor for Group<C> |
| where |
| C: ClientConfig + Clone, |
| { |
| type MlsRules = C::MlsRules; |
| type IdentityProvider = C::IdentityProvider; |
| type PreSharedKeyStorage = C::PskStore; |
| type OutputType = ReceivedMessage; |
| type CipherSuiteProvider = <C::CryptoProvider as CryptoProvider>::CipherSuiteProvider; |
| |
| #[cfg(feature = "private_message")] |
| fn self_index(&self) -> Option<LeafIndex> { |
| Some(self.private_tree.self_index) |
| } |
| |
| #[cfg(feature = "private_message")] |
| async fn process_ciphertext( |
| &mut self, |
| cipher_text: &PrivateMessage, |
| ) -> Result<EventOrContent<Self::OutputType>, MlsError> { |
| self.decrypt_incoming_ciphertext(cipher_text) |
| .await |
| .map(EventOrContent::Content) |
| } |
| |
| async fn verify_plaintext_authentication( |
| &self, |
| message: PublicMessage, |
| ) -> Result<EventOrContent<Self::OutputType>, MlsError> { |
| let auth_content = verify_plaintext_authentication( |
| &self.cipher_suite_provider, |
| message, |
| Some(&self.key_schedule), |
| Some(self.private_tree.self_index), |
| &self.state, |
| ) |
| .await?; |
| |
| Ok(EventOrContent::Content(auth_content)) |
| } |
| |
| async fn apply_update_path( |
| &mut self, |
| sender: LeafIndex, |
| update_path: &ValidatedUpdatePath, |
| provisional_state: &mut ProvisionalState, |
| ) -> Result<Option<(TreeKemPrivate, PathSecret)>, MlsError> { |
| // Update the private tree to create a provisional private tree |
| let (mut provisional_private_tree, new_signer) = |
| self.provisional_private_tree(provisional_state)?; |
| |
| if let Some(signer) = new_signer { |
| self.signer = signer; |
| } |
| |
| provisional_state |
| .public_tree |
| .apply_update_path( |
| sender, |
| update_path, |
| &provisional_state.group_context.extensions, |
| self.identity_provider(), |
| self.cipher_suite_provider(), |
| ) |
| .await?; |
| |
| if let Some(pending) = &self.pending_commit { |
| Ok(Some(( |
| pending.pending_private_tree.clone(), |
| pending.pending_commit_secret.clone(), |
| ))) |
| } else { |
| // Update the tree hash to get context for decryption |
| provisional_state.group_context.tree_hash = provisional_state |
| .public_tree |
| .tree_hash(&self.cipher_suite_provider) |
| .await?; |
| |
| let context_bytes = provisional_state.group_context.mls_encode_to_vec()?; |
| |
| TreeKem::new( |
| &mut provisional_state.public_tree, |
| &mut provisional_private_tree, |
| ) |
| .decap( |
| sender, |
| update_path, |
| &provisional_state.indexes_of_added_kpkgs, |
| &context_bytes, |
| &self.cipher_suite_provider, |
| ) |
| .await |
| .map(|root_secret| Some((provisional_private_tree, root_secret))) |
| } |
| } |
| |
| async fn update_key_schedule( |
| &mut self, |
| secrets: Option<(TreeKemPrivate, PathSecret)>, |
| interim_transcript_hash: InterimTranscriptHash, |
| confirmation_tag: &ConfirmationTag, |
| provisional_state: ProvisionalState, |
| ) -> Result<(), MlsError> { |
| let commit_secret = if let Some(secrets) = secrets { |
| self.private_tree = secrets.0; |
| secrets.1 |
| } else { |
| PathSecret::empty(&self.cipher_suite_provider) |
| }; |
| |
| // Use the commit_secret, the psk_secret, the provisional GroupContext, and the init secret |
| // from the previous epoch (or from the external init) to compute the epoch secret and |
| // derived secrets for the new epoch |
| |
| let key_schedule = match provisional_state |
| .applied_proposals |
| .external_initializations |
| .first() |
| .cloned() |
| { |
| Some(ext_init) if self.pending_commit.is_none() => { |
| self.key_schedule |
| .derive_for_external(&ext_init.proposal.kem_output, &self.cipher_suite_provider) |
| .await? |
| } |
| _ => self.key_schedule.clone(), |
| }; |
| |
| #[cfg(feature = "psk")] |
| let (psk, _) = self |
| .get_psk(&provisional_state.applied_proposals.psks) |
| .await?; |
| |
| #[cfg(not(feature = "psk"))] |
| let psk = self.get_psk(); |
| |
| let key_schedule_result = KeySchedule::from_key_schedule( |
| &key_schedule, |
| &commit_secret, |
| &provisional_state.group_context, |
| #[cfg(any(feature = "secret_tree_access", feature = "private_message"))] |
| provisional_state.public_tree.total_leaf_count(), |
| &psk, |
| &self.cipher_suite_provider, |
| ) |
| .await?; |
| |
| // Use the confirmation_key for the new epoch to compute the confirmation tag for |
| // this message, as described below, and verify that it is the same as the |
| // confirmation_tag field in the MlsPlaintext object. |
| let new_confirmation_tag = ConfirmationTag::create( |
| &key_schedule_result.confirmation_key, |
| &provisional_state.group_context.confirmed_transcript_hash, |
| &self.cipher_suite_provider, |
| ) |
| .await?; |
| |
| if &new_confirmation_tag != confirmation_tag { |
| return Err(MlsError::InvalidConfirmationTag); |
| } |
| |
| #[cfg(feature = "prior_epoch")] |
| let signature_public_keys = self |
| .state |
| .public_tree |
| .leaves() |
| .map(|l| l.map(|n| n.signing_identity.signature_key.clone())) |
| .collect(); |
| |
| #[cfg(feature = "prior_epoch")] |
| let past_epoch = PriorEpoch { |
| context: self.context().clone(), |
| self_index: self.private_tree.self_index, |
| secrets: self.epoch_secrets.clone(), |
| signature_public_keys, |
| }; |
| |
| #[cfg(feature = "prior_epoch")] |
| self.state_repo.insert(past_epoch).await?; |
| |
| self.epoch_secrets = key_schedule_result.epoch_secrets; |
| self.state.context = provisional_state.group_context; |
| self.state.interim_transcript_hash = interim_transcript_hash; |
| self.key_schedule = key_schedule_result.key_schedule; |
| self.state.public_tree = provisional_state.public_tree; |
| self.state.confirmation_tag = new_confirmation_tag; |
| |
| // Clear the proposals list |
| #[cfg(feature = "by_ref_proposal")] |
| self.state.proposals.clear(); |
| |
| // Clear the pending updates list |
| #[cfg(feature = "by_ref_proposal")] |
| { |
| self.pending_updates = Default::default(); |
| } |
| |
| self.pending_commit = None; |
| |
| Ok(()) |
| } |
| |
| fn mls_rules(&self) -> Self::MlsRules { |
| self.config.mls_rules() |
| } |
| |
| fn identity_provider(&self) -> Self::IdentityProvider { |
| self.config.identity_provider() |
| } |
| |
| fn psk_storage(&self) -> Self::PreSharedKeyStorage { |
| self.config.secret_store() |
| } |
| |
| fn group_state(&self) -> &GroupState { |
| &self.state |
| } |
| |
| fn group_state_mut(&mut self) -> &mut GroupState { |
| &mut self.state |
| } |
| |
| fn can_continue_processing(&self, provisional_state: &ProvisionalState) -> bool { |
| !(provisional_state |
| .applied_proposals |
| .removals |
| .iter() |
| .any(|p| p.proposal.to_remove == self.private_tree.self_index) |
| && self.pending_commit.is_none()) |
| } |
| |
| #[cfg(feature = "private_message")] |
| fn min_epoch_available(&self) -> Option<u64> { |
| None |
| } |
| |
| fn cipher_suite_provider(&self) -> &Self::CipherSuiteProvider { |
| &self.cipher_suite_provider |
| } |
| } |
| |
| #[cfg(test)] |
| pub(crate) mod test_utils; |
| |
| #[cfg(test)] |
| mod tests { |
| use crate::{ |
| client::test_utils::{ |
| test_client_with_key_pkg, TestClientBuilder, TEST_CIPHER_SUITE, |
| TEST_CUSTOM_PROPOSAL_TYPE, TEST_PROTOCOL_VERSION, |
| }, |
| client_builder::{test_utils::TestClientConfig, ClientBuilder, MlsConfig}, |
| crypto::test_utils::TestCryptoProvider, |
| group::{ |
| mls_rules::{CommitDirection, CommitSource}, |
| proposal_filter::ProposalBundle, |
| }, |
| identity::{ |
| basic::BasicIdentityProvider, |
| test_utils::{get_test_signing_identity, BasicWithCustomProvider}, |
| }, |
| key_package::test_utils::test_key_package_message, |
| mls_rules::CommitOptions, |
| tree_kem::{ |
| leaf_node::{test_utils::get_test_capabilities, LeafNodeSource}, |
| UpdatePathNode, |
| }, |
| }; |
| |
| #[cfg(any(feature = "private_message", feature = "custom_proposal"))] |
| use crate::group::mls_rules::DefaultMlsRules; |
| |
| #[cfg(feature = "prior_epoch")] |
| use crate::group::padding::PaddingMode; |
| |
| use crate::{extension::RequiredCapabilitiesExt, key_package::test_utils::test_key_package}; |
| |
| #[cfg(all(feature = "by_ref_proposal", feature = "custom_proposal"))] |
| use super::test_utils::test_group_custom_config; |
| |
| #[cfg(feature = "psk")] |
| use crate::{client::Client, psk::PreSharedKey}; |
| |
| #[cfg(any(feature = "by_ref_proposal", feature = "private_message"))] |
| use crate::group::test_utils::random_bytes; |
| |
| #[cfg(feature = "by_ref_proposal")] |
| use crate::{ |
| extension::test_utils::TestExtension, identity::test_utils::get_test_basic_credential, |
| time::MlsTime, |
| }; |
| |
| use super::{ |
| test_utils::{ |
| get_test_25519_key, get_test_groups_with_features, group_extensions, process_commit, |
| test_group, test_group_custom, test_n_member_group, TestGroup, TEST_GROUP, |
| }, |
| *, |
| }; |
| |
| use assert_matches::assert_matches; |
| |
| use mls_rs_core::extension::{Extension, ExtensionType}; |
| use mls_rs_core::identity::{Credential, CredentialType, CustomCredential}; |
| |
| #[cfg(feature = "by_ref_proposal")] |
| use mls_rs_core::identity::CertificateChain; |
| |
| #[cfg(feature = "state_update")] |
| use itertools::Itertools; |
| |
| #[cfg(feature = "state_update")] |
| use alloc::format; |
| |
| #[cfg(feature = "by_ref_proposal")] |
| use crate::{crypto::test_utils::test_cipher_suite_provider, extension::ExternalSendersExt}; |
| |
| #[cfg(any(feature = "private_message", feature = "state_update"))] |
| use super::test_utils::test_member; |
| |
| use mls_rs_core::extension::MlsExtension; |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_create_group() { |
| for (protocol_version, cipher_suite) in ProtocolVersion::all().flat_map(|p| { |
| TestCryptoProvider::all_supported_cipher_suites() |
| .into_iter() |
| .map(move |cs| (p, cs)) |
| }) { |
| let test_group = test_group(protocol_version, cipher_suite).await; |
| let group = test_group.group; |
| |
| assert_eq!(group.cipher_suite(), cipher_suite); |
| assert_eq!(group.state.context.epoch, 0); |
| assert_eq!(group.state.context.group_id, TEST_GROUP.to_vec()); |
| assert_eq!(group.state.context.extensions, group_extensions()); |
| |
| assert_eq!( |
| group.state.context.confirmed_transcript_hash, |
| ConfirmedTranscriptHash::from(vec![]) |
| ); |
| |
| #[cfg(feature = "private_message")] |
| assert!(group.state.proposals.is_empty()); |
| |
| #[cfg(feature = "by_ref_proposal")] |
| assert!(group.pending_updates.is_empty()); |
| |
| assert!(!group.has_pending_commit()); |
| |
| assert_eq!( |
| group.private_tree.self_index.0, |
| group.current_member_index() |
| ); |
| } |
| } |
| |
| #[cfg(feature = "private_message")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_pending_proposals_application_data() { |
| let mut test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| |
| // Create a proposal |
| let (bob_key_package, _) = |
| test_member(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, b"bob").await; |
| |
| let proposal = test_group |
| .group |
| .add_proposal(bob_key_package.key_package_message()) |
| .unwrap(); |
| |
| test_group |
| .group |
| .proposal_message(proposal, vec![]) |
| .await |
| .unwrap(); |
| |
| // We should not be able to send application messages until a commit happens |
| let res = test_group |
| .group |
| .encrypt_application_message(b"test", vec![]) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::CommitRequired)); |
| |
| // We should be able to send application messages after a commit |
| test_group.group.commit(vec![]).await.unwrap(); |
| |
| assert!(test_group.group.has_pending_commit()); |
| |
| test_group.group.apply_pending_commit().await.unwrap(); |
| |
| let res = test_group |
| .group |
| .encrypt_application_message(b"test", vec![]) |
| .await; |
| |
| assert!(res.is_ok()); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_update_proposals() { |
| let new_extension = TestExtension { foo: 10 }; |
| let mut extension_list = ExtensionList::default(); |
| extension_list.set_from(new_extension).unwrap(); |
| |
| let mut test_group = test_group_custom( |
| TEST_PROTOCOL_VERSION, |
| TEST_CIPHER_SUITE, |
| vec![42.into()], |
| Some(extension_list.clone()), |
| None, |
| ) |
| .await; |
| |
| let existing_leaf = test_group.group.current_user_leaf_node().unwrap().clone(); |
| |
| // Create an update proposal |
| let proposal = test_group.update_proposal().await; |
| |
| let update = match proposal { |
| Proposal::Update(update) => update, |
| _ => panic!("non update proposal found"), |
| }; |
| |
| assert_ne!(update.leaf_node.public_key, existing_leaf.public_key); |
| |
| assert_eq!( |
| update.leaf_node.signing_identity, |
| existing_leaf.signing_identity |
| ); |
| |
| assert_eq!(update.leaf_node.ungreased_extensions(), extension_list); |
| assert_eq!( |
| update.leaf_node.ungreased_capabilities().sorted(), |
| Capabilities { |
| extensions: vec![42.into()], |
| ..get_test_capabilities() |
| } |
| .sorted() |
| ); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_invalid_commit_self_update() { |
| let mut test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| |
| // Create an update proposal |
| let proposal_msg = test_group.group.propose_update(vec![]).await.unwrap(); |
| |
| let proposal = match proposal_msg.into_plaintext().unwrap().content.content { |
| Content::Proposal(p) => p, |
| _ => panic!("found non-proposal message"), |
| }; |
| |
| let update_leaf = match *proposal { |
| Proposal::Update(u) => u.leaf_node, |
| _ => panic!("found proposal message that isn't an update"), |
| }; |
| |
| test_group.group.commit(vec![]).await.unwrap(); |
| test_group.group.apply_pending_commit().await.unwrap(); |
| |
| // The leaf node should not be the one from the update, because the committer rejects it |
| assert_ne!( |
| &update_leaf, |
| test_group.group.current_user_leaf_node().unwrap() |
| ); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn update_proposal_with_bad_key_package_is_ignored_when_committing() { |
| let (mut alice_group, mut bob_group) = |
| test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, true).await; |
| |
| let mut proposal = alice_group.update_proposal().await; |
| |
| if let Proposal::Update(ref mut update) = proposal { |
| update.leaf_node.signature = random_bytes(32); |
| } else { |
| panic!("Invalid update proposal") |
| } |
| |
| let proposal_message = alice_group |
| .group |
| .proposal_message(proposal.clone(), vec![]) |
| .await |
| .unwrap(); |
| |
| let proposal_plaintext = match proposal_message.payload { |
| MlsMessagePayload::Plain(p) => p, |
| _ => panic!("Unexpected non-plaintext message"), |
| }; |
| |
| let proposal_ref = ProposalRef::from_content( |
| &bob_group.group.cipher_suite_provider, |
| &proposal_plaintext.clone().into(), |
| ) |
| .await |
| .unwrap(); |
| |
| // Hack bob's receipt of the proposal |
| bob_group.group.state.proposals.insert( |
| proposal_ref, |
| proposal, |
| proposal_plaintext.content.sender, |
| ); |
| |
| let commit_output = bob_group.group.commit(vec![]).await.unwrap(); |
| |
| assert_matches!( |
| commit_output.commit_message, |
| MlsMessage { |
| payload: MlsMessagePayload::Plain( |
| PublicMessage { |
| content: FramedContent { |
| content: Content::Commit(c), |
| .. |
| }, |
| .. |
| }), |
| .. |
| } if c.proposals.is_empty() |
| ); |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn test_two_member_group( |
| protocol_version: ProtocolVersion, |
| cipher_suite: CipherSuite, |
| tree_ext: bool, |
| ) -> (TestGroup, TestGroup) { |
| let mut test_group = test_group_custom( |
| protocol_version, |
| cipher_suite, |
| Default::default(), |
| None, |
| Some(CommitOptions::new().with_ratchet_tree_extension(tree_ext)), |
| ) |
| .await; |
| |
| let (bob_test_group, _) = test_group.join("bob").await; |
| |
| assert!(Group::equal_group_state( |
| &test_group.group, |
| &bob_test_group.group |
| )); |
| |
| (test_group, bob_test_group) |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_welcome_processing_exported_tree() { |
| test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, false).await; |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_welcome_processing_tree_extension() { |
| test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, true).await; |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_welcome_processing_missing_tree() { |
| let mut test_group = test_group_custom( |
| TEST_PROTOCOL_VERSION, |
| TEST_CIPHER_SUITE, |
| Default::default(), |
| None, |
| Some(CommitOptions::new().with_ratchet_tree_extension(false)), |
| ) |
| .await; |
| |
| let (bob_client, bob_key_package) = |
| test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await; |
| |
| // Add bob to the group |
| let commit_output = test_group |
| .group |
| .commit_builder() |
| .add_member(bob_key_package) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| // Group from Bob's perspective |
| let bob_group = Group::join( |
| &commit_output.welcome_messages[0], |
| None, |
| bob_client.config, |
| bob_client.signer.unwrap(), |
| ) |
| .await |
| .map(|_| ()); |
| |
| assert_matches!(bob_group, Err(MlsError::RatchetTreeNotFound)); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_group_context_ext_proposal_create() { |
| let test_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| |
| let mut extension_list = ExtensionList::new(); |
| extension_list |
| .set_from(RequiredCapabilitiesExt { |
| extensions: vec![42.into()], |
| proposals: vec![], |
| credentials: vec![], |
| }) |
| .unwrap(); |
| |
| let proposal = test_group |
| .group |
| .group_context_extensions_proposal(extension_list.clone()); |
| |
| assert_matches!(proposal, Proposal::GroupContextExtensions(ext) if ext == extension_list); |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn group_context_extension_proposal_test( |
| ext_list: ExtensionList, |
| ) -> (TestGroup, Result<MlsMessage, MlsError>) { |
| let protocol_version = TEST_PROTOCOL_VERSION; |
| let cipher_suite = TEST_CIPHER_SUITE; |
| |
| let mut test_group = |
| test_group_custom(protocol_version, cipher_suite, vec![42.into()], None, None).await; |
| |
| let commit = test_group |
| .group |
| .commit_builder() |
| .set_group_context_ext(ext_list) |
| .unwrap() |
| .build() |
| .await |
| .map(|commit_output| commit_output.commit_message); |
| |
| (test_group, commit) |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_group_context_ext_proposal_commit() { |
| let mut extension_list = ExtensionList::new(); |
| |
| extension_list |
| .set_from(RequiredCapabilitiesExt { |
| extensions: vec![42.into()], |
| proposals: vec![], |
| credentials: vec![], |
| }) |
| .unwrap(); |
| |
| let (mut test_group, _) = |
| group_context_extension_proposal_test(extension_list.clone()).await; |
| |
| #[cfg(feature = "state_update")] |
| { |
| let update = test_group.group.apply_pending_commit().await.unwrap(); |
| assert!(update.state_update.active); |
| } |
| |
| #[cfg(not(feature = "state_update"))] |
| test_group.group.apply_pending_commit().await.unwrap(); |
| |
| assert_eq!(test_group.group.state.context.extensions, extension_list) |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_group_context_ext_proposal_invalid() { |
| let mut extension_list = ExtensionList::new(); |
| extension_list |
| .set_from(RequiredCapabilitiesExt { |
| extensions: vec![999.into()], |
| proposals: vec![], |
| credentials: vec![], |
| }) |
| .unwrap(); |
| |
| let (_, commit) = group_context_extension_proposal_test(extension_list.clone()).await; |
| |
| assert_matches!( |
| commit, |
| Err(MlsError::RequiredExtensionNotFound(a)) if a == 999.into() |
| ); |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn make_group_with_required_capabilities( |
| required_caps: RequiredCapabilitiesExt, |
| ) -> Result<Group<TestClientConfig>, MlsError> { |
| test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "alice") |
| .await |
| .0 |
| .create_group(core::iter::once(required_caps.into_extension().unwrap()).collect()) |
| .await |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn creating_group_with_member_not_supporting_required_credential_type_fails() { |
| let group_creation = make_group_with_required_capabilities(RequiredCapabilitiesExt { |
| credentials: vec![CredentialType::BASIC, CredentialType::X509], |
| ..Default::default() |
| }) |
| .await |
| .map(|_| ()); |
| |
| assert_matches!( |
| group_creation, |
| Err(MlsError::RequiredCredentialNotFound(CredentialType::X509)) |
| ); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn creating_group_with_member_not_supporting_required_extension_type_fails() { |
| const EXTENSION_TYPE: ExtensionType = ExtensionType::new(33); |
| |
| let group_creation = make_group_with_required_capabilities(RequiredCapabilitiesExt { |
| extensions: vec![EXTENSION_TYPE], |
| ..Default::default() |
| }) |
| .await |
| .map(|_| ()); |
| |
| assert_matches!( |
| group_creation, |
| Err(MlsError::RequiredExtensionNotFound(EXTENSION_TYPE)) |
| ); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn creating_group_with_member_not_supporting_required_proposal_type_fails() { |
| const PROPOSAL_TYPE: ProposalType = ProposalType::new(33); |
| |
| let group_creation = make_group_with_required_capabilities(RequiredCapabilitiesExt { |
| proposals: vec![PROPOSAL_TYPE], |
| ..Default::default() |
| }) |
| .await |
| .map(|_| ()); |
| |
| assert_matches!( |
| group_creation, |
| Err(MlsError::RequiredProposalNotFound(PROPOSAL_TYPE)) |
| ); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg(not(target_arch = "wasm32"))] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn creating_group_with_member_not_supporting_external_sender_credential_fails() { |
| let ext_senders = make_x509_external_senders_ext() |
| .await |
| .into_extension() |
| .unwrap(); |
| |
| let group_creation = |
| test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "alice") |
| .await |
| .0 |
| .create_group(core::iter::once(ext_senders).collect()) |
| .await |
| .map(|_| ()); |
| |
| assert_matches!( |
| group_creation, |
| Err(MlsError::RequiredCredentialNotFound(CredentialType::X509)) |
| ); |
| } |
| |
| #[cfg(all(not(target_arch = "wasm32"), feature = "private_message"))] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_group_encrypt_plaintext_padding() { |
| let protocol_version = TEST_PROTOCOL_VERSION; |
| // This test requires a cipher suite whose signatures are not variable in length. |
| let cipher_suite = CipherSuite::CURVE25519_AES128; |
| |
| let mut test_group = test_group_custom_config(protocol_version, cipher_suite, |b| { |
| b.mls_rules( |
| DefaultMlsRules::default() |
| .with_encryption_options(EncryptionOptions::new(true, PaddingMode::None)), |
| ) |
| }) |
| .await; |
| |
| let without_padding = test_group |
| .group |
| .encrypt_application_message(&random_bytes(150), vec![]) |
| .await |
| .unwrap(); |
| |
| let mut test_group = |
| test_group_custom_config(protocol_version, cipher_suite, |b| { |
| b.mls_rules(DefaultMlsRules::default().with_encryption_options( |
| EncryptionOptions::new(true, PaddingMode::StepFunction), |
| )) |
| }) |
| .await; |
| |
| let with_padding = test_group |
| .group |
| .encrypt_application_message(&random_bytes(150), vec![]) |
| .await |
| .unwrap(); |
| |
| assert!(with_padding.mls_encoded_len() > without_padding.mls_encoded_len()); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn external_commit_requires_external_pub_extension() { |
| let protocol_version = TEST_PROTOCOL_VERSION; |
| let cipher_suite = TEST_CIPHER_SUITE; |
| let group = test_group(protocol_version, cipher_suite).await; |
| |
| let info = group |
| .group |
| .group_info_message(false) |
| .await |
| .unwrap() |
| .into_group_info() |
| .unwrap(); |
| |
| let info_msg = MlsMessage::new(protocol_version, MlsMessagePayload::GroupInfo(info)); |
| |
| let signing_identity = group |
| .group |
| .current_member_signing_identity() |
| .unwrap() |
| .clone(); |
| |
| let res = external_commit::ExternalCommitBuilder::new( |
| group.group.signer, |
| signing_identity, |
| group.group.config, |
| ) |
| .build(info_msg) |
| .await |
| .map(|_| {}); |
| |
| assert_matches!(res, Err(MlsError::MissingExternalPubExtension)); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn external_commit_via_commit_options_round_trip() { |
| let mut group = test_group_custom( |
| TEST_PROTOCOL_VERSION, |
| TEST_CIPHER_SUITE, |
| vec![], |
| None, |
| CommitOptions::default() |
| .with_allow_external_commit(true) |
| .into(), |
| ) |
| .await; |
| |
| let commit_output = group.group.commit(vec![]).await.unwrap(); |
| |
| let (test_client, _) = |
| test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await; |
| |
| test_client |
| .external_commit_builder() |
| .unwrap() |
| .build(commit_output.external_commit_group_info.unwrap()) |
| .await |
| .unwrap(); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_path_update_preference() { |
| let protocol_version = TEST_PROTOCOL_VERSION; |
| let cipher_suite = TEST_CIPHER_SUITE; |
| |
| let mut test_group = test_group_custom( |
| protocol_version, |
| cipher_suite, |
| Default::default(), |
| None, |
| Some(CommitOptions::new()), |
| ) |
| .await; |
| |
| let test_key_package = |
| test_key_package_message(protocol_version, cipher_suite, "alice").await; |
| |
| test_group |
| .group |
| .commit_builder() |
| .add_member(test_key_package.clone()) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| assert!(test_group |
| .group |
| .pending_commit |
| .unwrap() |
| .pending_commit_secret |
| .iter() |
| .all(|x| x == &0)); |
| |
| let mut test_group = test_group_custom( |
| protocol_version, |
| cipher_suite, |
| Default::default(), |
| None, |
| Some(CommitOptions::new().with_path_required(true)), |
| ) |
| .await; |
| |
| test_group |
| .group |
| .commit_builder() |
| .add_member(test_key_package) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| assert!(!test_group |
| .group |
| .pending_commit |
| .unwrap() |
| .pending_commit_secret |
| .iter() |
| .all(|x| x == &0)); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_path_update_preference_override() { |
| let protocol_version = TEST_PROTOCOL_VERSION; |
| let cipher_suite = TEST_CIPHER_SUITE; |
| |
| let mut test_group = test_group_custom( |
| protocol_version, |
| cipher_suite, |
| Default::default(), |
| None, |
| Some(CommitOptions::new()), |
| ) |
| .await; |
| |
| test_group.group.commit(vec![]).await.unwrap(); |
| |
| assert!(!test_group |
| .group |
| .pending_commit |
| .unwrap() |
| .pending_commit_secret |
| .iter() |
| .all(|x| x == &0)); |
| } |
| |
| #[cfg(feature = "private_message")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn group_rejects_unencrypted_application_message() { |
| let protocol_version = TEST_PROTOCOL_VERSION; |
| let cipher_suite = TEST_CIPHER_SUITE; |
| |
| let mut alice = test_group(protocol_version, cipher_suite).await; |
| let (mut bob, _) = alice.join("bob").await; |
| |
| let message = alice |
| .make_plaintext(Content::Application(b"hello".to_vec().into())) |
| .await; |
| |
| let res = bob.group.process_incoming_message(message).await; |
| |
| assert_matches!(res, Err(MlsError::UnencryptedApplicationMessage)); |
| } |
| |
| #[cfg(feature = "state_update")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_state_update() { |
| let protocol_version = TEST_PROTOCOL_VERSION; |
| let cipher_suite = TEST_CIPHER_SUITE; |
| |
| // Create a group with 10 members |
| let mut alice = test_group(protocol_version, cipher_suite).await; |
| let (mut bob, _) = alice.join("bob").await; |
| let mut leaves = vec![]; |
| |
| for i in 0..8 { |
| let (group, commit) = alice.join(&format!("charlie{i}")).await; |
| leaves.push(group.group.current_user_leaf_node().unwrap().clone()); |
| bob.process_message(commit).await.unwrap(); |
| } |
| |
| // Create many proposals, make Alice commit them |
| |
| let update_message = bob.group.propose_update(vec![]).await.unwrap(); |
| |
| alice.process_message(update_message).await.unwrap(); |
| |
| let external_psk_ids: Vec<ExternalPskId> = (0..5) |
| .map(|i| { |
| let external_id = ExternalPskId::new(vec![i]); |
| |
| alice |
| .group |
| .config |
| .secret_store() |
| .insert(ExternalPskId::new(vec![i]), PreSharedKey::from(vec![i])); |
| |
| bob.group |
| .config |
| .secret_store() |
| .insert(ExternalPskId::new(vec![i]), PreSharedKey::from(vec![i])); |
| |
| external_id |
| }) |
| .collect(); |
| |
| let mut commit_builder = alice.group.commit_builder(); |
| |
| for external_psk in external_psk_ids { |
| commit_builder = commit_builder.add_external_psk(external_psk).unwrap(); |
| } |
| |
| for index in [2, 5, 6] { |
| commit_builder = commit_builder.remove_member(index).unwrap(); |
| } |
| |
| for i in 0..5 { |
| let (key_package, _) = test_member( |
| protocol_version, |
| cipher_suite, |
| format!("dave{i}").as_bytes(), |
| ) |
| .await; |
| |
| commit_builder = commit_builder |
| .add_member(key_package.key_package_message()) |
| .unwrap() |
| } |
| |
| let commit_output = commit_builder.build().await.unwrap(); |
| |
| let commit_description = alice.process_pending_commit().await.unwrap(); |
| |
| assert!(!commit_description.is_external); |
| |
| assert_eq!( |
| commit_description.committer, |
| alice.group.current_member_index() |
| ); |
| |
| // Check that applying pending commit and processing commit yields correct update. |
| let state_update_alice = commit_description.state_update.clone(); |
| |
| assert_eq!( |
| state_update_alice |
| .roster_update |
| .added() |
| .iter() |
| .map(|m| m.index) |
| .collect::<Vec<_>>(), |
| vec![2, 5, 6, 10, 11] |
| ); |
| |
| assert_eq!( |
| state_update_alice.roster_update.removed(), |
| vec![2, 5, 6] |
| .into_iter() |
| .map(|i| member_from_leaf_node(&leaves[i as usize - 2], LeafIndex(i))) |
| .collect::<Vec<_>>() |
| ); |
| |
| assert_eq!( |
| state_update_alice |
| .roster_update |
| .updated() |
| .iter() |
| .map(|update| update.new.clone()) |
| .collect_vec() |
| .as_slice(), |
| &alice.group.roster().members()[0..2] |
| ); |
| |
| assert_eq!( |
| state_update_alice.added_psks, |
| (0..5) |
| .map(|i| ExternalPskId::new(vec![i])) |
| .collect::<Vec<_>>() |
| ); |
| |
| let payload = bob |
| .process_message(commit_output.commit_message) |
| .await |
| .unwrap(); |
| |
| let ReceivedMessage::Commit(bob_commit_description) = payload else { |
| panic!("expected commit"); |
| }; |
| |
| assert_eq!(commit_description, bob_commit_description); |
| } |
| |
| #[cfg(feature = "state_update")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_description_external_commit() { |
| use crate::client::test_utils::TestClientBuilder; |
| |
| let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| |
| let (bob_identity, secret_key) = get_test_signing_identity(TEST_CIPHER_SUITE, b"bob").await; |
| |
| let bob = TestClientBuilder::new_for_test() |
| .signing_identity(bob_identity, secret_key, TEST_CIPHER_SUITE) |
| .build(); |
| |
| let (bob_group, commit) = bob |
| .external_commit_builder() |
| .unwrap() |
| .build( |
| alice_group |
| .group |
| .group_info_message_allowing_ext_commit(true) |
| .await |
| .unwrap(), |
| ) |
| .await |
| .unwrap(); |
| |
| let event = alice_group.process_message(commit).await.unwrap(); |
| |
| let ReceivedMessage::Commit(commit_description) = event else { |
| panic!("expected commit"); |
| }; |
| |
| assert!(commit_description.is_external); |
| assert_eq!(commit_description.committer, 1); |
| |
| assert_eq!( |
| commit_description.state_update.roster_update.added(), |
| &bob_group.roster().members()[1..2] |
| ); |
| |
| itertools::assert_equal( |
| bob_group.roster().members_iter(), |
| alice_group.group.roster().members_iter(), |
| ); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn can_join_new_group_externally() { |
| use crate::client::test_utils::TestClientBuilder; |
| |
| let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| |
| let (bob_identity, secret_key) = get_test_signing_identity(TEST_CIPHER_SUITE, b"bob").await; |
| |
| let bob = TestClientBuilder::new_for_test() |
| .signing_identity(bob_identity, secret_key, TEST_CIPHER_SUITE) |
| .build(); |
| |
| let (_, commit) = bob |
| .external_commit_builder() |
| .unwrap() |
| .with_tree_data(alice_group.group.export_tree().into_owned()) |
| .build( |
| alice_group |
| .group |
| .group_info_message_allowing_ext_commit(false) |
| .await |
| .unwrap(), |
| ) |
| .await |
| .unwrap(); |
| |
| alice_group.process_message(commit).await.unwrap(); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_membership_tag_from_non_member() { |
| let (mut alice_group, mut bob_group) = |
| test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, true).await; |
| |
| let mut commit_output = alice_group.group.commit(vec![]).await.unwrap(); |
| |
| let plaintext = match commit_output.commit_message.payload { |
| MlsMessagePayload::Plain(ref mut plain) => plain, |
| _ => panic!("Non plaintext message"), |
| }; |
| |
| plaintext.content.sender = Sender::NewMemberCommit; |
| |
| let res = bob_group |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::MembershipTagForNonMember)); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn test_partial_commits() { |
| let protocol_version = TEST_PROTOCOL_VERSION; |
| let cipher_suite = TEST_CIPHER_SUITE; |
| |
| let mut alice = test_group(protocol_version, cipher_suite).await; |
| let (mut bob, _) = alice.join("bob").await; |
| let (mut charlie, commit) = alice.join("charlie").await; |
| bob.process_message(commit).await.unwrap(); |
| |
| let (_, commit) = charlie.join("dave").await; |
| |
| alice.process_message(commit.clone()).await.unwrap(); |
| bob.process_message(commit.clone()).await.unwrap(); |
| |
| let Content::Commit(commit) = commit.into_plaintext().unwrap().content.content else { |
| panic!("Expected commit") |
| }; |
| |
| assert!(commit.path.is_none()); |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn group_with_path_required() -> TestGroup { |
| let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| |
| alice.group.config.0.mls_rules.commit_options.path_required = true; |
| |
| alice |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn old_hpke_secrets_are_removed() { |
| let mut alice = group_with_path_required().await; |
| alice.join("bob").await; |
| alice.join("charlie").await; |
| |
| alice |
| .group |
| .commit_builder() |
| .remove_member(1) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| assert!(alice.group.private_tree.secret_keys[1].is_some()); |
| alice.process_pending_commit().await.unwrap(); |
| assert!(alice.group.private_tree.secret_keys[1].is_none()); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn old_hpke_secrets_of_removed_are_removed() { |
| let mut alice = group_with_path_required().await; |
| alice.join("bob").await; |
| let (mut charlie, _) = alice.join("charlie").await; |
| |
| let commit = charlie |
| .group |
| .commit_builder() |
| .remove_member(1) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| assert!(alice.group.private_tree.secret_keys[1].is_some()); |
| alice.process_message(commit.commit_message).await.unwrap(); |
| assert!(alice.group.private_tree.secret_keys[1].is_none()); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn old_hpke_secrets_of_updated_are_removed() { |
| let mut alice = group_with_path_required().await; |
| let (mut bob, _) = alice.join("bob").await; |
| let (mut charlie, commit) = alice.join("charlie").await; |
| bob.process_message(commit).await.unwrap(); |
| |
| let update = bob.group.propose_update(vec![]).await.unwrap(); |
| charlie.process_message(update.clone()).await.unwrap(); |
| alice.process_message(update).await.unwrap(); |
| |
| let commit = charlie.group.commit(vec![]).await.unwrap(); |
| |
| assert!(alice.group.private_tree.secret_keys[1].is_some()); |
| alice.process_message(commit.commit_message).await.unwrap(); |
| assert!(alice.group.private_tree.secret_keys[1].is_none()); |
| } |
| |
| #[cfg(feature = "psk")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn only_selected_members_of_the_original_group_can_join_subgroup() { |
| let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| let (mut bob, _) = alice.join("bob").await; |
| let (carol, commit) = alice.join("carol").await; |
| |
| // Apply the commit that adds carol |
| bob.group.process_incoming_message(commit).await.unwrap(); |
| |
| let bob_identity = bob.group.current_member_signing_identity().unwrap().clone(); |
| let signer = bob.group.signer.clone(); |
| |
| let new_key_pkg = Client::new( |
| bob.group.config.clone(), |
| Some(signer), |
| Some((bob_identity, TEST_CIPHER_SUITE)), |
| TEST_PROTOCOL_VERSION, |
| ) |
| .generate_key_package_message() |
| .await |
| .unwrap(); |
| |
| let (mut alice_sub_group, welcome) = alice |
| .group |
| .branch(b"subgroup".to_vec(), vec![new_key_pkg]) |
| .await |
| .unwrap(); |
| |
| let welcome = &welcome[0]; |
| |
| let (mut bob_sub_group, _) = bob.group.join_subgroup(welcome, None).await.unwrap(); |
| |
| // Carol can't join |
| let res = carol.group.join_subgroup(welcome, None).await.map(|_| ()); |
| assert_matches!(res, Err(_)); |
| |
| // Alice and Bob can still talk |
| let commit_output = alice_sub_group.commit(vec![]).await.unwrap(); |
| |
| bob_sub_group |
| .process_incoming_message(commit_output.commit_message) |
| .await |
| .unwrap(); |
| } |
| |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn joining_group_fails_if_unsupported<F>( |
| f: F, |
| ) -> Result<(TestGroup, MlsMessage), MlsError> |
| where |
| F: FnMut(&mut TestClientConfig), |
| { |
| let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| alice_group.join_with_custom_config("alice", false, f).await |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn joining_group_fails_if_protocol_version_is_not_supported() { |
| let res = joining_group_fails_if_unsupported(|config| { |
| config.0.settings.protocol_versions.clear(); |
| }) |
| .await |
| .map(|_| ()); |
| |
| assert_matches!( |
| res, |
| Err(MlsError::UnsupportedProtocolVersion(v)) if v == |
| TEST_PROTOCOL_VERSION |
| ); |
| } |
| |
| // WebCrypto does not support disabling ciphersuites |
| #[cfg(not(target_arch = "wasm32"))] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn joining_group_fails_if_cipher_suite_is_not_supported() { |
| let res = joining_group_fails_if_unsupported(|config| { |
| config |
| .0 |
| .crypto_provider |
| .enabled_cipher_suites |
| .retain(|&x| x != TEST_CIPHER_SUITE); |
| }) |
| .await |
| .map(|_| ()); |
| |
| assert_matches!( |
| res, |
| Err(MlsError::UnsupportedCipherSuite(TEST_CIPHER_SUITE)) |
| ); |
| } |
| |
| #[cfg(feature = "private_message")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn member_can_see_sender_creds() { |
| let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| let (mut bob_group, _) = alice_group.join("bob").await; |
| |
| let bob_msg = b"I'm Bob"; |
| |
| let msg = bob_group |
| .group |
| .encrypt_application_message(bob_msg, vec![]) |
| .await |
| .unwrap(); |
| |
| let received_by_alice = alice_group |
| .group |
| .process_incoming_message(msg) |
| .await |
| .unwrap(); |
| |
| assert_matches!( |
| received_by_alice, |
| ReceivedMessage::ApplicationMessage(ApplicationMessageDescription { sender_index, .. }) |
| if sender_index == bob_group.group.current_member_index() |
| ); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn members_of_a_group_have_identical_authentication_secrets() { |
| let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| let (bob_group, _) = alice_group.join("bob").await; |
| |
| assert_eq!( |
| alice_group.group.epoch_authenticator().unwrap(), |
| bob_group.group.epoch_authenticator().unwrap() |
| ); |
| } |
| |
| #[cfg(feature = "private_message")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn member_cannot_decrypt_same_message_twice() { |
| let mut alice_group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; |
| let (mut bob_group, _) = alice_group.join("bob").await; |
| |
| let message = alice_group |
| .group |
| .encrypt_application_message(b"foobar", Vec::new()) |
| .await |
| .unwrap(); |
| |
| let received_message = bob_group |
| .group |
| .process_incoming_message(message.clone()) |
| .await |
| .unwrap(); |
| |
| assert_matches!( |
| received_message, |
| ReceivedMessage::ApplicationMessage(m) if m.data() == b"foobar" |
| ); |
| |
| let res = bob_group.group.process_incoming_message(message).await; |
| |
| assert_matches!(res, Err(MlsError::KeyMissing(0))); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn removing_requirements_allows_to_add() { |
| let mut alice_group = test_group_custom( |
| TEST_PROTOCOL_VERSION, |
| TEST_CIPHER_SUITE, |
| vec![17.into()], |
| None, |
| None, |
| ) |
| .await; |
| |
| alice_group |
| .group |
| .commit_builder() |
| .set_group_context_ext( |
| vec![RequiredCapabilitiesExt { |
| extensions: vec![17.into()], |
| ..Default::default() |
| } |
| .into_extension() |
| .unwrap()] |
| .try_into() |
| .unwrap(), |
| ) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| alice_group.process_pending_commit().await.unwrap(); |
| |
| let test_key_package = |
| test_key_package(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await; |
| |
| let test_key_package = MlsMessage::new( |
| TEST_PROTOCOL_VERSION, |
| MlsMessagePayload::KeyPackage(test_key_package), |
| ); |
| |
| alice_group |
| .group |
| .commit_builder() |
| .add_member(test_key_package) |
| .unwrap() |
| .set_group_context_ext(Default::default()) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| let state_update = alice_group |
| .process_pending_commit() |
| .await |
| .unwrap() |
| .state_update; |
| |
| #[cfg(feature = "state_update")] |
| assert_eq!( |
| state_update |
| .roster_update |
| .added() |
| .iter() |
| .map(|m| m.index) |
| .collect::<Vec<_>>(), |
| vec![1] |
| ); |
| |
| #[cfg(not(feature = "state_update"))] |
| assert!(state_update == StateUpdate {}); |
| |
| assert_eq!(alice_group.group.roster().members_iter().count(), 2); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_wrong_source() { |
| // RFC, 13.4.2. "The leaf_node_source field MUST be set to commit." |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 3).await; |
| |
| groups[0].group.commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.leaf_node_source = LeafNodeSource::Update; |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| |
| let res = groups[2] |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::InvalidLeafNodeSource)); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_same_hpke_key() { |
| // RFC 13.4.2. "Verify that the encryption_key value in the LeafNode is different from the committer's current leaf node" |
| |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 3).await; |
| |
| // Group 0 starts using fixed key |
| groups[0].group.commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.public_key = get_test_25519_key(1u8); |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| groups[0].process_pending_commit().await.unwrap(); |
| groups[2] |
| .process_message(commit_output.commit_message) |
| .await |
| .unwrap(); |
| |
| // Group 0 tries to use the fixed key againd |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| |
| let res = groups[2] |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::SameHpkeKey(0))); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_duplicate_hpke_key() { |
| // RFC 8.3 "Verify that the following fields are unique among the members of the group: `encryption_key`" |
| |
| if TEST_CIPHER_SUITE != CipherSuite::CURVE25519_AES128 |
| && TEST_CIPHER_SUITE != CipherSuite::CURVE25519_CHACHA |
| { |
| return; |
| } |
| |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await; |
| |
| // Group 1 uses the fixed key |
| groups[1].group.commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.public_key = get_test_25519_key(1u8); |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups |
| .get_mut(1) |
| .unwrap() |
| .group |
| .commit(vec![]) |
| .await |
| .unwrap(); |
| |
| process_commit(&mut groups, commit_output.commit_message, 1).await; |
| |
| // Group 0 tries to use the fixed key too |
| groups[0].group.commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.public_key = get_test_25519_key(1u8); |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| |
| let res = groups[7] |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::DuplicateLeafData(_))); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_duplicate_signature_key() { |
| // RFC 8.3 "Verify that the following fields are unique among the members of the group: `signature_key`" |
| |
| if TEST_CIPHER_SUITE != CipherSuite::CURVE25519_AES128 |
| && TEST_CIPHER_SUITE != CipherSuite::CURVE25519_CHACHA |
| { |
| return; |
| } |
| |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await; |
| |
| // Group 1 uses the fixed key |
| groups[1].group.commit_modifiers.modify_leaf = |leaf, _| { |
| let sk = hex!( |
| "3468b4c890255c983e3d5cbf5cb64c1ef7f6433a518f2f3151d6672f839a06ebcad4fc381fe61822af45135c82921a348e6f46643d66ddefc70483565433714b" |
| ) |
| .into(); |
| |
| leaf.signing_identity.signature_key = |
| hex!("cad4fc381fe61822af45135c82921a348e6f46643d66ddefc70483565433714b").into(); |
| |
| Some(sk) |
| }; |
| |
| let commit_output = groups |
| .get_mut(1) |
| .unwrap() |
| .group |
| .commit(vec![]) |
| .await |
| .unwrap(); |
| |
| process_commit(&mut groups, commit_output.commit_message, 1).await; |
| |
| // Group 0 tries to use the fixed key too |
| groups[0].group.commit_modifiers.modify_leaf = |leaf, _| { |
| let sk = hex!( |
| "3468b4c890255c983e3d5cbf5cb64c1ef7f6433a518f2f3151d6672f839a06ebcad4fc381fe61822af45135c82921a348e6f46643d66ddefc70483565433714b" |
| ) |
| .into(); |
| |
| leaf.signing_identity.signature_key = |
| hex!("cad4fc381fe61822af45135c82921a348e6f46643d66ddefc70483565433714b").into(); |
| |
| Some(sk) |
| }; |
| |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| |
| let res = groups[7] |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::DuplicateLeafData(_))); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_incorrect_signature() { |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 3).await; |
| |
| groups[0].group.commit_modifiers.modify_leaf = |leaf, _| { |
| leaf.signature[0] ^= 1; |
| None |
| }; |
| |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| |
| let res = groups[2] |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::InvalidSignature)); |
| } |
| |
| #[cfg(not(target_arch = "wasm32"))] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_not_supporting_used_context_extension() { |
| const EXT_TYPE: ExtensionType = ExtensionType::new(999); |
| |
| // The new leaf of the committer doesn't support an extension set in group context |
| let extension = Extension::new(EXT_TYPE, vec![]); |
| |
| let mut groups = |
| get_test_groups_with_features(3, vec![extension].into(), Default::default()).await; |
| |
| groups[0].commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.capabilities = get_test_capabilities(); |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].commit(vec![]).await.unwrap(); |
| |
| let res = groups[1] |
| .process_incoming_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::UnsupportedGroupExtension(EXT_TYPE))); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_not_supporting_required_extension() { |
| // The new leaf of the committer doesn't support an extension required by group context |
| |
| let extension = RequiredCapabilitiesExt { |
| extensions: vec![999.into()], |
| proposals: vec![], |
| credentials: vec![], |
| }; |
| |
| let extensions = vec![extension.into_extension().unwrap()]; |
| let mut groups = |
| get_test_groups_with_features(3, extensions.into(), Default::default()).await; |
| |
| groups[0].commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.capabilities = Capabilities::default(); |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].commit(vec![]).await.unwrap(); |
| |
| let res = groups[2] |
| .process_incoming_message(commit_output.commit_message) |
| .await; |
| |
| assert!(res.is_err()); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_has_unsupported_credential() { |
| // The new leaf of the committer has a credential unsupported by another leaf |
| let mut groups = |
| get_test_groups_with_features(3, Default::default(), Default::default()).await; |
| |
| for group in groups.iter_mut() { |
| group.config.0.identity_provider.allow_any_custom = true; |
| } |
| |
| groups[0].commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.signing_identity.credential = Credential::Custom(CustomCredential::new( |
| CredentialType::new(43), |
| leaf.signing_identity |
| .credential |
| .as_basic() |
| .unwrap() |
| .identifier |
| .to_vec(), |
| )); |
| |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].commit(vec![]).await.unwrap(); |
| |
| let res = groups[2] |
| .process_incoming_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::CredentialTypeOfNewLeafIsUnsupported)); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_not_supporting_credential_used_in_another_leaf() { |
| // The new leaf of the committer doesn't support another leaf's credential |
| |
| let mut groups = |
| get_test_groups_with_features(3, Default::default(), Default::default()).await; |
| |
| groups[0].commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.capabilities.credentials = vec![2.into()]; |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].commit(vec![]).await.unwrap(); |
| |
| let res = groups[2] |
| .process_incoming_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::InUseCredentialTypeUnsupportedByNewLeaf)); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_not_supporting_required_credential() { |
| // The new leaf of the committer doesn't support a credential required by group context |
| |
| let extension = RequiredCapabilitiesExt { |
| extensions: vec![], |
| proposals: vec![], |
| credentials: vec![1.into()], |
| }; |
| |
| let extensions = vec![extension.into_extension().unwrap()]; |
| let mut groups = |
| get_test_groups_with_features(3, extensions.into(), Default::default()).await; |
| |
| groups[0].commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.capabilities.credentials = vec![2.into()]; |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].commit(vec![]).await.unwrap(); |
| |
| let res = groups[2] |
| .process_incoming_message(commit_output.commit_message) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::RequiredCredentialNotFound(_))); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg(not(target_arch = "wasm32"))] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn make_x509_external_senders_ext() -> ExternalSendersExt { |
| let (_, ext_sender_pk) = test_cipher_suite_provider(TEST_CIPHER_SUITE) |
| .signature_key_generate() |
| .await |
| .unwrap(); |
| |
| let ext_sender_id = SigningIdentity { |
| signature_key: ext_sender_pk, |
| credential: Credential::X509(CertificateChain::from(vec![random_bytes(32)])), |
| }; |
| |
| ExternalSendersExt::new(vec![ext_sender_id]) |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg(not(target_arch = "wasm32"))] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_leaf_not_supporting_external_sender_credential_leads_to_rejected_commit() { |
| let ext_senders = make_x509_external_senders_ext() |
| .await |
| .into_extension() |
| .unwrap(); |
| |
| let mut alice = ClientBuilder::new() |
| .crypto_provider(TestCryptoProvider::new()) |
| .identity_provider( |
| BasicWithCustomProvider::default().with_credential_type(CredentialType::X509), |
| ) |
| .with_random_signing_identity("alice", TEST_CIPHER_SUITE) |
| .await |
| .build() |
| .create_group(core::iter::once(ext_senders).collect()) |
| .await |
| .unwrap(); |
| |
| // New leaf supports only basic credentials (used by the group) but not X509 used by external sender |
| alice.commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.capabilities.credentials = vec![CredentialType::BASIC]; |
| Some(sk.clone()) |
| }; |
| |
| alice.commit(vec![]).await.unwrap(); |
| let res = alice.apply_pending_commit().await; |
| |
| assert_matches!( |
| res, |
| Err(MlsError::RequiredCredentialNotFound(CredentialType::X509)) |
| ); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg(not(target_arch = "wasm32"))] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn node_not_supporting_external_sender_credential_cannot_join_group() { |
| let ext_senders = make_x509_external_senders_ext() |
| .await |
| .into_extension() |
| .unwrap(); |
| |
| let mut alice = ClientBuilder::new() |
| .crypto_provider(TestCryptoProvider::new()) |
| .identity_provider( |
| BasicWithCustomProvider::default().with_credential_type(CredentialType::X509), |
| ) |
| .with_random_signing_identity("alice", TEST_CIPHER_SUITE) |
| .await |
| .build() |
| .create_group(core::iter::once(ext_senders).collect()) |
| .await |
| .unwrap(); |
| |
| let (_, bob_key_pkg) = |
| test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await; |
| |
| let commit = alice |
| .commit_builder() |
| .add_member(bob_key_pkg) |
| .unwrap() |
| .build() |
| .await; |
| |
| assert_matches!( |
| commit, |
| Err(MlsError::RequiredCredentialNotFound(CredentialType::X509)) |
| ); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[cfg(not(target_arch = "wasm32"))] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn external_senders_extension_is_rejected_if_member_does_not_support_credential_type() { |
| let mut alice = ClientBuilder::new() |
| .crypto_provider(TestCryptoProvider::new()) |
| .identity_provider( |
| BasicWithCustomProvider::default().with_credential_type(CredentialType::X509), |
| ) |
| .with_random_signing_identity("alice", TEST_CIPHER_SUITE) |
| .await |
| .build() |
| .create_group(Default::default()) |
| .await |
| .unwrap(); |
| |
| let (_, bob_key_pkg) = |
| test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await; |
| |
| alice |
| .commit_builder() |
| .add_member(bob_key_pkg) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| alice.apply_pending_commit().await.unwrap(); |
| assert_eq!(alice.roster().members_iter().count(), 2); |
| |
| let ext_senders = make_x509_external_senders_ext() |
| .await |
| .into_extension() |
| .unwrap(); |
| |
| let res = alice |
| .commit_builder() |
| .set_group_context_ext(core::iter::once(ext_senders).collect()) |
| .unwrap() |
| .build() |
| .await; |
| |
| assert_matches!( |
| res, |
| Err(MlsError::RequiredCredentialNotFound(CredentialType::X509)) |
| ); |
| } |
| |
| /* |
| * Edge case paths |
| */ |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn committing_degenerate_path_succeeds() { |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await; |
| |
| groups[0].group.commit_modifiers.modify_tree = |tree: &mut TreeKemPublic| { |
| tree.update_node(get_test_25519_key(1u8), 1).unwrap(); |
| tree.update_node(get_test_25519_key(1u8), 3).unwrap(); |
| }; |
| |
| groups[0].group.commit_modifiers.modify_leaf = |leaf, sk| { |
| leaf.public_key = get_test_25519_key(1u8); |
| Some(sk.clone()) |
| }; |
| |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| |
| let res = groups[7] |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| assert!(res.is_ok()); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn inserting_key_in_filtered_node_fails() { |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await; |
| |
| let commit_output = groups[0] |
| .group |
| .commit_builder() |
| .remove_member(1) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| groups[0].process_pending_commit().await.unwrap(); |
| |
| for group in groups.iter_mut().skip(2) { |
| group |
| .process_message(commit_output.commit_message.clone()) |
| .await |
| .unwrap(); |
| } |
| |
| groups[0].group.commit_modifiers.modify_tree = |tree: &mut TreeKemPublic| { |
| tree.update_node(get_test_25519_key(1u8), 1).unwrap(); |
| }; |
| |
| groups[0].group.commit_modifiers.modify_path = |path: Vec<UpdatePathNode>| { |
| let mut path = path; |
| let mut node = path[0].clone(); |
| node.public_key = get_test_25519_key(1u8); |
| path.insert(0, node); |
| path |
| }; |
| |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| |
| let res = groups[7] |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| // We should get a path validation error, since the path is too long |
| assert_matches!(res, Err(MlsError::WrongPathLen)); |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn commit_with_too_short_path_fails() { |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 10).await; |
| |
| let commit_output = groups[0] |
| .group |
| .commit_builder() |
| .remove_member(1) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| groups[0].process_pending_commit().await.unwrap(); |
| |
| for group in groups.iter_mut().skip(2) { |
| group |
| .process_message(commit_output.commit_message.clone()) |
| .await |
| .unwrap(); |
| } |
| |
| groups[0].group.commit_modifiers.modify_path = |path: Vec<UpdatePathNode>| { |
| let mut path = path; |
| path.pop(); |
| path |
| }; |
| |
| let commit_output = groups[0].group.commit(vec![]).await.unwrap(); |
| |
| let res = groups[7] |
| .process_message(commit_output.commit_message) |
| .await; |
| |
| assert!(res.is_err()); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn update_proposal_can_change_credential() { |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 3).await; |
| let (identity, secret_key) = get_test_signing_identity(TEST_CIPHER_SUITE, b"member").await; |
| |
| let update = groups[0] |
| .group |
| .propose_update_with_identity(secret_key, identity.clone(), vec![]) |
| .await |
| .unwrap(); |
| |
| groups[1].process_message(update).await.unwrap(); |
| let commit_output = groups[1].group.commit(vec![]).await.unwrap(); |
| |
| // Check that the credential was updated by in the committer's state. |
| groups[1].process_pending_commit().await.unwrap(); |
| let new_member = groups[1].group.roster().member_with_index(0).unwrap(); |
| |
| assert_eq!( |
| new_member.signing_identity.credential, |
| get_test_basic_credential(b"member".to_vec()) |
| ); |
| |
| assert_eq!( |
| new_member.signing_identity.signature_key, |
| identity.signature_key |
| ); |
| |
| // Check that the credential was updated in the updater's state. |
| groups[0] |
| .process_message(commit_output.commit_message) |
| .await |
| .unwrap(); |
| let new_member = groups[0].group.roster().member_with_index(0).unwrap(); |
| |
| assert_eq!( |
| new_member.signing_identity.credential, |
| get_test_basic_credential(b"member".to_vec()) |
| ); |
| |
| assert_eq!( |
| new_member.signing_identity.signature_key, |
| identity.signature_key |
| ); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn receiving_commit_with_old_adds_fails() { |
| let mut groups = test_n_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, 2).await; |
| |
| let key_package = |
| test_key_package_message(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "foobar").await; |
| |
| let proposal = groups[0] |
| .group |
| .propose_add(key_package, vec![]) |
| .await |
| .unwrap(); |
| |
| let commit = groups[0].group.commit(vec![]).await.unwrap().commit_message; |
| |
| // 10 years from now |
| let future_time = MlsTime::now().seconds_since_epoch() + 10 * 365 * 24 * 3600; |
| |
| let future_time = |
| MlsTime::from_duration_since_epoch(core::time::Duration::from_secs(future_time)); |
| |
| groups[1] |
| .group |
| .process_incoming_message(proposal) |
| .await |
| .unwrap(); |
| let res = groups[1] |
| .group |
| .process_incoming_message_with_time(commit, future_time) |
| .await; |
| |
| assert_matches!(res, Err(MlsError::InvalidLifetime)); |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn custom_proposal_setup() -> (TestGroup, TestGroup) { |
| let mut alice = test_group_custom_config(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, |b| { |
| b.custom_proposal_type(TEST_CUSTOM_PROPOSAL_TYPE) |
| }) |
| .await; |
| |
| let (bob, _) = alice |
| .join_with_custom_config("bob", true, |c| { |
| c.0.settings |
| .custom_proposal_types |
| .push(TEST_CUSTOM_PROPOSAL_TYPE) |
| }) |
| .await |
| .unwrap(); |
| |
| (alice, bob) |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn custom_proposal_by_value() { |
| let (mut alice, mut bob) = custom_proposal_setup().await; |
| |
| let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![0, 1, 2]); |
| |
| let commit = alice |
| .group |
| .commit_builder() |
| .custom_proposal(custom_proposal.clone()) |
| .build() |
| .await |
| .unwrap() |
| .commit_message; |
| |
| let res = bob.group.process_incoming_message(commit).await.unwrap(); |
| |
| #[cfg(feature = "state_update")] |
| assert_matches!(res, ReceivedMessage::Commit(CommitMessageDescription { state_update: StateUpdate { custom_proposals, .. }, .. }) |
| if custom_proposals.len() == 1 && custom_proposals[0].proposal == custom_proposal); |
| |
| #[cfg(not(feature = "state_update"))] |
| assert_matches!(res, ReceivedMessage::Commit(_)); |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn custom_proposal_by_reference() { |
| let (mut alice, mut bob) = custom_proposal_setup().await; |
| |
| let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![0, 1, 2]); |
| |
| let proposal = alice |
| .group |
| .propose_custom(custom_proposal.clone(), vec![]) |
| .await |
| .unwrap(); |
| |
| let recv_prop = bob.group.process_incoming_message(proposal).await.unwrap(); |
| |
| assert_matches!(recv_prop, ReceivedMessage::Proposal(ProposalMessageDescription { proposal: Proposal::Custom(c), ..}) |
| if c == custom_proposal); |
| |
| let commit = bob.group.commit(vec![]).await.unwrap().commit_message; |
| let res = alice.group.process_incoming_message(commit).await.unwrap(); |
| |
| #[cfg(feature = "state_update")] |
| assert_matches!(res, ReceivedMessage::Commit(CommitMessageDescription { state_update: StateUpdate { custom_proposals, .. }, .. }) |
| if custom_proposals.len() == 1 && custom_proposals[0].proposal == custom_proposal); |
| |
| #[cfg(not(feature = "state_update"))] |
| assert_matches!(res, ReceivedMessage::Commit(_)); |
| } |
| |
| #[cfg(feature = "psk")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn can_join_with_psk() { |
| let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE) |
| .await |
| .group; |
| |
| let (bob, key_pkg) = |
| test_client_with_key_pkg(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, "bob").await; |
| |
| let psk_id = ExternalPskId::new(vec![0]); |
| let psk = PreSharedKey::from(vec![0]); |
| |
| alice |
| .config |
| .secret_store() |
| .insert(psk_id.clone(), psk.clone()); |
| |
| bob.config.secret_store().insert(psk_id.clone(), psk); |
| |
| let commit = alice |
| .commit_builder() |
| .add_member(key_pkg) |
| .unwrap() |
| .add_external_psk(psk_id) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| bob.join_group(None, &commit.welcome_messages[0]) |
| .await |
| .unwrap(); |
| } |
| |
| #[cfg(feature = "by_ref_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn invalid_update_does_not_prevent_other_updates() { |
| const EXTENSION_TYPE: ExtensionType = ExtensionType::new(33); |
| |
| let group_extensions = ExtensionList::from(vec![RequiredCapabilitiesExt { |
| extensions: vec![EXTENSION_TYPE], |
| ..Default::default() |
| } |
| .into_extension() |
| .unwrap()]); |
| |
| // Alice creates a group requiring support for an extension |
| let mut alice = TestClientBuilder::new_for_test() |
| .with_random_signing_identity("alice", TEST_CIPHER_SUITE) |
| .await |
| .extension_type(EXTENSION_TYPE) |
| .build() |
| .create_group(group_extensions.clone()) |
| .await |
| .unwrap(); |
| |
| let (bob_signing_identity, bob_secret_key) = |
| get_test_signing_identity(TEST_CIPHER_SUITE, b"bob").await; |
| |
| let bob_client = TestClientBuilder::new_for_test() |
| .signing_identity( |
| bob_signing_identity.clone(), |
| bob_secret_key.clone(), |
| TEST_CIPHER_SUITE, |
| ) |
| .extension_type(EXTENSION_TYPE) |
| .build(); |
| |
| let carol_client = TestClientBuilder::new_for_test() |
| .with_random_signing_identity("carol", TEST_CIPHER_SUITE) |
| .await |
| .extension_type(EXTENSION_TYPE) |
| .build(); |
| |
| let dave_client = TestClientBuilder::new_for_test() |
| .with_random_signing_identity("dave", TEST_CIPHER_SUITE) |
| .await |
| .extension_type(EXTENSION_TYPE) |
| .build(); |
| |
| // Alice adds Bob, Carol and Dave to the group. They all support the mandatory extension. |
| let commit = alice |
| .commit_builder() |
| .add_member(bob_client.generate_key_package_message().await.unwrap()) |
| .unwrap() |
| .add_member(carol_client.generate_key_package_message().await.unwrap()) |
| .unwrap() |
| .add_member(dave_client.generate_key_package_message().await.unwrap()) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| alice.apply_pending_commit().await.unwrap(); |
| |
| let mut bob = bob_client |
| .join_group(None, &commit.welcome_messages[0]) |
| .await |
| .unwrap() |
| .0; |
| |
| bob.write_to_storage().await.unwrap(); |
| |
| // Bob reloads his group data, but with parameters that will cause his generated leaves to |
| // not support the mandatory extension. |
| let mut bob = TestClientBuilder::new_for_test() |
| .signing_identity(bob_signing_identity, bob_secret_key, TEST_CIPHER_SUITE) |
| .key_package_repo(bob.config.key_package_repo()) |
| .group_state_storage(bob.config.group_state_storage()) |
| .build() |
| .load_group(alice.group_id()) |
| .await |
| .unwrap(); |
| |
| let mut carol = carol_client |
| .join_group(None, &commit.welcome_messages[0]) |
| .await |
| .unwrap() |
| .0; |
| |
| let mut dave = dave_client |
| .join_group(None, &commit.welcome_messages[0]) |
| .await |
| .unwrap() |
| .0; |
| |
| // Bob's updated leaf does not support the mandatory extension. |
| let bob_update = bob.propose_update(Vec::new()).await.unwrap(); |
| let carol_update = carol.propose_update(Vec::new()).await.unwrap(); |
| let dave_update = dave.propose_update(Vec::new()).await.unwrap(); |
| |
| // Alice receives the update proposals to be committed. |
| alice.process_incoming_message(bob_update).await.unwrap(); |
| alice.process_incoming_message(carol_update).await.unwrap(); |
| alice.process_incoming_message(dave_update).await.unwrap(); |
| |
| // Alice commits the update proposals. |
| alice.commit(Vec::new()).await.unwrap(); |
| let commit_desc = alice.apply_pending_commit().await.unwrap(); |
| |
| let find_update_for = |id: &str| { |
| commit_desc |
| .state_update |
| .roster_update |
| .updated() |
| .iter() |
| .filter_map(|u| u.prior.signing_identity.credential.as_basic()) |
| .any(|c| c.identifier == id.as_bytes()) |
| }; |
| |
| // Check that all updates preserve identities. |
| let identities_are_preserved = commit_desc |
| .state_update |
| .roster_update |
| .updated() |
| .iter() |
| .filter_map(|u| { |
| let before = &u.prior.signing_identity.credential.as_basic()?.identifier; |
| let after = &u.new.signing_identity.credential.as_basic()?.identifier; |
| Some((before, after)) |
| }) |
| .all(|(before, after)| before == after); |
| |
| assert!(identities_are_preserved); |
| |
| // Carol's and Dave's updates should be part of the commit. |
| assert!(find_update_for("carol")); |
| assert!(find_update_for("dave")); |
| |
| // Bob's update should be rejected. |
| assert!(!find_update_for("bob")); |
| |
| // Check that all members are still in the group. |
| let all_members_are_in = alice |
| .roster() |
| .members_iter() |
| .zip(["alice", "bob", "carol", "dave"]) |
| .all(|(member, id)| { |
| member |
| .signing_identity |
| .credential |
| .as_basic() |
| .unwrap() |
| .identifier |
| == id.as_bytes() |
| }); |
| |
| assert!(all_members_are_in); |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn custom_proposal_may_enforce_path() { |
| test_custom_proposal_mls_rules(true).await; |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn custom_proposal_need_not_enforce_path() { |
| test_custom_proposal_mls_rules(false).await; |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn test_custom_proposal_mls_rules(path_required_for_custom: bool) { |
| let mls_rules = CustomMlsRules { |
| path_required_for_custom, |
| external_joiner_can_send_custom: true, |
| }; |
| |
| let mut alice = client_with_custom_rules(b"alice", mls_rules.clone()) |
| .await |
| .create_group(Default::default()) |
| .await |
| .unwrap(); |
| |
| let alice_pub_before = alice.current_user_leaf_node().unwrap().public_key.clone(); |
| |
| let kp = client_with_custom_rules(b"bob", mls_rules) |
| .await |
| .generate_key_package_message() |
| .await |
| .unwrap(); |
| |
| alice |
| .commit_builder() |
| .custom_proposal(CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![])) |
| .add_member(kp) |
| .unwrap() |
| .build() |
| .await |
| .unwrap(); |
| |
| alice.apply_pending_commit().await.unwrap(); |
| |
| let alice_pub_after = &alice.current_user_leaf_node().unwrap().public_key; |
| |
| if path_required_for_custom { |
| assert_ne!(alice_pub_after, &alice_pub_before); |
| } else { |
| assert_eq!(alice_pub_after, &alice_pub_before); |
| } |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn custom_proposal_by_value_in_external_join_may_be_allowed() { |
| test_custom_proposal_by_value_in_external_join(true).await |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn custom_proposal_by_value_in_external_join_may_not_be_allowed() { |
| test_custom_proposal_by_value_in_external_join(false).await |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn test_custom_proposal_by_value_in_external_join(external_joiner_can_send_custom: bool) { |
| let mls_rules = CustomMlsRules { |
| path_required_for_custom: true, |
| external_joiner_can_send_custom, |
| }; |
| |
| let mut alice = client_with_custom_rules(b"alice", mls_rules.clone()) |
| .await |
| .create_group(Default::default()) |
| .await |
| .unwrap(); |
| |
| let group_info = alice |
| .group_info_message_allowing_ext_commit(true) |
| .await |
| .unwrap(); |
| |
| let commit = client_with_custom_rules(b"bob", mls_rules) |
| .await |
| .external_commit_builder() |
| .unwrap() |
| .with_custom_proposal(CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![])) |
| .build(group_info) |
| .await; |
| |
| if external_joiner_can_send_custom { |
| let commit = commit.unwrap().1; |
| alice.process_incoming_message(commit).await.unwrap(); |
| } else { |
| assert_matches!(commit.map(|_| ()), Err(MlsError::MlsRulesError(_))); |
| } |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn custom_proposal_by_ref_in_external_join() { |
| let mls_rules = CustomMlsRules { |
| path_required_for_custom: true, |
| external_joiner_can_send_custom: true, |
| }; |
| |
| let mut alice = client_with_custom_rules(b"alice", mls_rules.clone()) |
| .await |
| .create_group(Default::default()) |
| .await |
| .unwrap(); |
| |
| let by_ref = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![]); |
| let by_ref = alice.propose_custom(by_ref, vec![]).await.unwrap(); |
| |
| let group_info = alice |
| .group_info_message_allowing_ext_commit(true) |
| .await |
| .unwrap(); |
| |
| let (_, commit) = client_with_custom_rules(b"bob", mls_rules) |
| .await |
| .external_commit_builder() |
| .unwrap() |
| .with_received_custom_proposal(by_ref) |
| .build(group_info) |
| .await |
| .unwrap(); |
| |
| alice.process_incoming_message(commit).await.unwrap(); |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| async fn client_with_custom_rules( |
| name: &[u8], |
| mls_rules: CustomMlsRules, |
| ) -> Client<impl MlsConfig> { |
| let (signing_identity, signer) = get_test_signing_identity(TEST_CIPHER_SUITE, name).await; |
| |
| ClientBuilder::new() |
| .crypto_provider(TestCryptoProvider::new()) |
| .identity_provider(BasicWithCustomProvider::new(BasicIdentityProvider::new())) |
| .signing_identity(signing_identity, signer, TEST_CIPHER_SUITE) |
| .custom_proposal_type(TEST_CUSTOM_PROPOSAL_TYPE) |
| .mls_rules(mls_rules) |
| .build() |
| } |
| |
| #[derive(Debug, Clone)] |
| struct CustomMlsRules { |
| path_required_for_custom: bool, |
| external_joiner_can_send_custom: bool, |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| impl ProposalBundle { |
| fn has_test_custom_proposal(&self) -> bool { |
| self.custom_proposal_types() |
| .any(|t| t == TEST_CUSTOM_PROPOSAL_TYPE) |
| } |
| } |
| |
| #[cfg(feature = "custom_proposal")] |
| #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] |
| #[cfg_attr(mls_build_async, maybe_async::must_be_async)] |
| impl crate::MlsRules for CustomMlsRules { |
| type Error = MlsError; |
| |
| fn commit_options( |
| &self, |
| _: &Roster, |
| _: &ExtensionList, |
| proposals: &ProposalBundle, |
| ) -> Result<CommitOptions, MlsError> { |
| Ok(CommitOptions::default().with_path_required( |
| !proposals.has_test_custom_proposal() || self.path_required_for_custom, |
| )) |
| } |
| |
| fn encryption_options( |
| &self, |
| _: &Roster, |
| _: &ExtensionList, |
| ) -> Result<crate::mls_rules::EncryptionOptions, MlsError> { |
| Ok(Default::default()) |
| } |
| |
| async fn filter_proposals( |
| &self, |
| _: CommitDirection, |
| sender: CommitSource, |
| _: &Roster, |
| _: &ExtensionList, |
| proposals: ProposalBundle, |
| ) -> Result<ProposalBundle, MlsError> { |
| let is_external = matches!(sender, CommitSource::NewMember(_)); |
| let has_custom = proposals.has_test_custom_proposal(); |
| let allowed = !has_custom || !is_external || self.external_joiner_can_send_custom; |
| |
| allowed.then_some(proposals).ok_or(MlsError::InvalidSender) |
| } |
| } |
| |
| #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] |
| async fn group_can_receive_commit_from_self() { |
| let mut group = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE) |
| .await |
| .group; |
| |
| let commit = group.commit(vec![]).await.unwrap(); |
| |
| let update = group |
| .process_incoming_message(commit.commit_message) |
| .await |
| .unwrap(); |
| |
| let ReceivedMessage::Commit(update) = update else { |
| panic!("expected commit message") |
| }; |
| |
| assert_eq!(update.committer, *group.private_tree.self_index); |
| } |
| } |