diff --git a/mls-rs-core/Cargo.toml b/mls-rs-core/Cargo.toml index 2bb8d34d..d68cb270 100644 --- a/mls-rs-core/Cargo.toml +++ b/mls-rs-core/Cargo.toml @@ -23,6 +23,7 @@ serde = ["dep:serde", "zeroize/serde", "hex/serde", "dep:serde_bytes"] last_resort_key_package_ext = [] post-quantum = [] self_remove_proposal = [] +gsma_rcs_e2ee_feature = [] [dependencies] mls-rs-codec = { version = "0.6", path = "../mls-rs-codec", default-features = false} diff --git a/mls-rs-core/src/group/proposal_type.rs b/mls-rs-core/src/group/proposal_type.rs index 889e11cd..93c678cd 100644 --- a/mls-rs-core/src/group/proposal_type.rs +++ b/mls-rs-core/src/group/proposal_type.rs @@ -58,6 +58,8 @@ impl ProposalType { pub const GROUP_CONTEXT_EXTENSIONS: ProposalType = ProposalType(7); #[cfg(feature = "self_remove_proposal")] pub const SELF_REMOVE: ProposalType = ProposalType(0xF003); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + pub const SERVER_REMOVE: ProposalType = ProposalType(0xF004); /// Default proposal types defined /// in [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420.html#name-leaf-node-contents) diff --git a/mls-rs/Cargo.toml b/mls-rs/Cargo.toml index ba22501d..be76d8d6 100644 --- a/mls-rs/Cargo.toml +++ b/mls-rs/Cargo.toml @@ -36,6 +36,7 @@ x509 = ["mls-rs-core/x509", "dep:mls-rs-identity-x509"] rfc_compliant = ["private_message", "custom_proposal", "out_of_order", "psk", "x509", "prior_epoch", "by_ref_proposal", "mls-rs-core/rfc_compliant"] last_resort_key_package_ext = ["mls-rs-core/last_resort_key_package_ext"] self_remove_proposal = ["mls-rs-core/self_remove_proposal"] +gsma_rcs_e2ee_feature = ["mls-rs-core/gsma_rcs_e2ee_feature"] std = ["mls-rs-core/std", "mls-rs-codec/std", "mls-rs-identity-x509?/std", "hex/std", "futures/std", "itertools/use_std", "safer-ffi-gen?/std", "zeroize/std", "dep:debug_tree", "dep:thiserror", "serde?/std"] diff --git a/mls-rs/src/external_client/group.rs b/mls-rs/src/external_client/group.rs index c7b53f51..714ecd26 100644 --- a/mls-rs/src/external_client/group.rs +++ b/mls-rs/src/external_client/group.rs @@ -43,6 +43,8 @@ use crate::{ feature = "self_remove_proposal" ))] use crate::group::proposal::SelfRemoveProposal; +#[cfg(feature = "gsma_rcs_e2ee_feature")] +use crate::group::proposal::ServerRemoveProposal; #[cfg(feature = "by_ref_proposal")] use crate::{ @@ -699,6 +701,15 @@ where None } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)] + fn server_removal_proposal( + &self, + _provisional_state: &ProvisionalState, + ) -> Option> { + None + } + #[cfg(feature = "private_message")] fn min_epoch_available(&self) -> Option { self.config diff --git a/mls-rs/src/group/commit.rs b/mls-rs/src/group/commit.rs index 67fa2947..dc497a40 100644 --- a/mls-rs/src/group/commit.rs +++ b/mls-rs/src/group/commit.rs @@ -225,6 +225,15 @@ where Ok(self) } + /// Insert a [`ServerRemoveProposal`](crate::group::proposal::RemoveProposal) into + /// the current commit that is being built. + #[cfg(feature = "gsma_rcs_e2ee_feature")] + pub fn server_remove_member(mut self, index: u32) -> Result { + let proposal = self.group.server_remove_proposal(index)?; + self.proposals.push(proposal); + Ok(self) + } + /// Insert a /// [`GroupContextExtensions`](crate::group::proposal::Proposal::GroupContextExtensions) /// into the current commit that is being built. diff --git a/mls-rs/src/group/external_commit.rs b/mls-rs/src/group/external_commit.rs index 14e0b777..84e26726 100644 --- a/mls-rs/src/group/external_commit.rs +++ b/mls-rs/src/group/external_commit.rs @@ -18,6 +18,9 @@ use crate::{ Group, MlsMessage, }; +#[cfg(feature = "gsma_rcs_e2ee_feature")] +use crate::group::proposal::ServerRemoveProposal; + #[cfg(any(feature = "secret_tree_access", feature = "private_message"))] use crate::group::secret_tree::SecretTree; @@ -52,6 +55,8 @@ pub struct ExternalCommitBuilder { config: C, tree_data: Option>, to_remove: Option, + #[cfg(feature = "gsma_rcs_e2ee_feature")] + to_server_remove: Vec, #[cfg(feature = "psk")] external_psks: Vec, authenticated_data: Vec, @@ -70,6 +75,8 @@ impl ExternalCommitBuilder { Self { tree_data: None, to_remove: None, + #[cfg(feature = "gsma_rcs_e2ee_feature")] + to_server_remove: Vec::new(), authenticated_data: Vec::new(), signer, signing_identity, @@ -104,6 +111,21 @@ impl ExternalCommitBuilder { } } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[must_use] + /// Propose the server-removal of a client as part of the external commit. + pub fn with_server_removal(self, to_remove: u32) -> Self { + Self { + to_server_remove: self + .to_server_remove + .iter() + .chain([&to_remove]) + .cloned() + .collect(), + ..self + } + } + #[must_use] /// Add plaintext authenticated data to the resulting commit message. pub fn with_authenticated_data(self, data: Vec) -> Self { @@ -265,6 +287,13 @@ impl ExternalCommitBuilder { })); } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + for index_to_remove in self.to_server_remove { + proposals.push(Proposal::ServerRemove(ServerRemoveProposal { + to_remove: LeafIndex(index_to_remove), + })); + } + let (commit_output, pending_commit) = group .commit_internal( proposals, diff --git a/mls-rs/src/group/message_processor.rs b/mls-rs/src/group/message_processor.rs index 1273e035..2f600220 100644 --- a/mls-rs/src/group/message_processor.rs +++ b/mls-rs/src/group/message_processor.rs @@ -8,6 +8,8 @@ feature = "self_remove_proposal" ))] use super::SelfRemoveProposal; +#[cfg(feature = "gsma_rcs_e2ee_feature")] +use super::ServerRemoveProposal; use super::{ commit_sender, confirmation_tag::ConfirmationTag, @@ -91,6 +93,9 @@ pub(crate) fn path_update_required(proposals: &ProposalBundle) -> bool { ))] let res = res || !proposals.self_removes.is_empty(); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let res = res || !proposals.server_removes.is_empty(); + res || proposals.length() == 0 || proposals.group_context_extensions_proposal().is_some() || !proposals.remove_proposals().is_empty() @@ -727,6 +732,9 @@ pub(crate) trait MessageProcessor: Send + Sync { ))] let self_removed_by_self = self.self_removal_proposal(&provisional_state); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let self_removed_by_server = self.server_removal_proposal(&provisional_state); + let is_self_removed = self_removed.is_some(); #[cfg(all( feature = "by_ref_proposal", @@ -735,6 +743,9 @@ pub(crate) trait MessageProcessor: Send + Sync { ))] let is_self_removed = is_self_removed || self_removed_by_self.is_some(); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let is_self_removed = is_self_removed || self_removed_by_server.is_some(); + let update_path = match commit.path { Some(update_path) => Some( validate_update_path( @@ -783,6 +794,17 @@ pub(crate) trait MessageProcessor: Send + Sync { commit_effect }; + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let commit_effect = if let Some(server_remove_proposal) = self_removed_by_server { + let new_epoch = NewEpoch::new(self.group_state().clone(), &provisional_state); + CommitEffect::Removed { + remover: server_remove_proposal.sender, + new_epoch: Box::new(new_epoch), + } + } else { + commit_effect + }; + let new_secrets = match update_path { Some(update_path) if !is_self_removed => { self.apply_update_path(sender, &update_path, &mut provisional_state) @@ -851,6 +873,13 @@ pub(crate) trait MessageProcessor: Send + Sync { provisional_state: &ProvisionalState, ) -> Option>; + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)] + fn server_removal_proposal( + &self, + provisional_state: &ProvisionalState, + ) -> Option>; + #[cfg(feature = "private_message")] fn min_epoch_available(&self) -> Option; diff --git a/mls-rs/src/group/mod.rs b/mls-rs/src/group/mod.rs index d7854ccb..261d865e 100644 --- a/mls-rs/src/group/mod.rs +++ b/mls-rs/src/group/mod.rs @@ -1078,6 +1078,31 @@ where self.proposal_message(proposal, authenticated_data).await } + #[cfg(all(feature = "gsma_rcs_e2ee_feature", feature = "by_ref_proposal"))] + #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)] + #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] + pub async fn propose_server_remove( + &mut self, + index: u32, + authenticated_data: Vec, + ) -> Result { + let proposal = self.server_remove_proposal(index)?; + self.proposal_message(proposal, authenticated_data).await + } + + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)] + fn server_remove_proposal(&self, index: u32) -> Result { + 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::ServerRemove(ServerRemoveProposal { + 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 @@ -2316,6 +2341,20 @@ where .cloned() } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)] + fn server_removal_proposal( + &self, + provisional_state: &ProvisionalState, + ) -> Option> { + provisional_state + .applied_proposals + .server_removes + .iter() + .find(|p| p.proposal.to_remove == self.private_tree.self_index) + .cloned() + } + #[cfg(feature = "private_message")] fn min_epoch_available(&self) -> Option { None @@ -4538,17 +4577,15 @@ mod tests { #[cfg(feature = "custom_proposal")] #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] - async fn custom_proposal_setup() -> (TestGroup, TestGroup) { + async fn custom_proposal_setup(prop_type: ProposalType) -> (TestGroup, TestGroup) { let mut alice = test_group_custom_config(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, |b| { - b.custom_proposal_type(TEST_CUSTOM_PROPOSAL_TYPE) + b.custom_proposal_type(prop_type) }) .await; let (bob, _) = alice .join_with_custom_config("bob", true, |c| { - c.0.settings - .custom_proposal_types - .push(TEST_CUSTOM_PROPOSAL_TYPE) + c.0.settings.custom_proposal_types.push(prop_type) }) .await .unwrap(); @@ -4556,34 +4593,329 @@ mod tests { (alice, bob) } + #[cfg(all(feature = "gsma_rcs_e2ee_feature", feature = "by_ref_proposal"))] + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn server_remove_removes_client() { + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SERVER_REMOVE).await; + + let propose_server_remove_bob = alice.propose_server_remove(1, Vec::new()).await.unwrap(); + let commit = alice.commit_builder().build().await.unwrap().commit_message; + + bob.process_incoming_message(propose_server_remove_bob) + .await + .unwrap(); + + let ReceivedMessage::Commit(CommitMessageDescription { + effect: CommitEffect::NewEpoch(new_epoch), + .. + }) = alice + .process_incoming_message(commit.clone()) + .await + .unwrap() + else { + panic!("unexpected commit effect"); + }; + + assert_eq!(new_epoch.applied_proposals.len(), 1); + + let ReceivedMessage::Commit(CommitMessageDescription { + effect: CommitEffect::Removed { .. }, + .. + }) = bob.process_incoming_message(commit).await.unwrap() + else { + panic!("unexpected commit effect"); + }; + } + + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn server_remove_removes_client_by_value() { + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SERVER_REMOVE).await; + + let commit_builder = alice.commit_builder(); + let commit_builder = commit_builder.server_remove_member(1).await.unwrap(); + let commit = commit_builder.build().await.unwrap().commit_message; + + let ReceivedMessage::Commit(CommitMessageDescription { + effect: CommitEffect::NewEpoch(new_epoch), + .. + }) = alice + .process_incoming_message(commit.clone()) + .await + .unwrap() + else { + panic!("unexpected commit effect"); + }; + + assert_eq!(new_epoch.applied_proposals.len(), 1); + + let ReceivedMessage::Commit(CommitMessageDescription { + effect: CommitEffect::Removed { .. }, + .. + }) = bob.process_incoming_message(commit).await.unwrap() + else { + panic!("unexpected commit effect"); + }; + } + + #[cfg(all(feature = "gsma_rcs_e2ee_feature", feature = "by_ref_proposal"))] + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn commit_with_both_remove_and_server_remove_for_same_client_leaves_server_remove_unused() + { + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SERVER_REMOVE).await; + + // Bob proposes server-remove of self. + let bob_self_remove = bob.propose_server_remove(1, Vec::new()).await.unwrap(); + + // Alice receives the self-remove proposal to be committed. + alice + .process_incoming_message(bob_self_remove.clone()) + .await + .unwrap(); + + // Alice also removes Bob with a regular remove proposal. + alice.propose_remove(1, Vec::new()).await.unwrap(); + + // Alice commits Bob's self-remove and Alice's removal of Bob. This filters out the remove proposal. + let commit = alice.commit(Vec::new()).await.unwrap(); + let unused = &commit.unused_proposals[0]; + assert_matches!( + unused, + ProposalInfo { + proposal: Proposal::ServerRemove(ServerRemoveProposal { + to_remove: LeafIndex(1) + }), + sender: Sender::Member(1), + .. + } + ); + } + #[cfg(all( feature = "by_ref_proposal", feature = "custom_proposal", - feature = "self_remove_proposal" + feature = "gsma_rcs_e2ee_feature" ))] - #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] - async fn self_remove_group_setup() -> (TestGroup, TestGroup) { - let mut alice = test_group_custom_config(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, |b| { - b.custom_proposal_type(ProposalType::SELF_REMOVE) - }) - .await; + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn external_commit_can_have_server_removes_by_reference() { + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SERVER_REMOVE).await; - let (bob, _) = alice - .join_with_custom_config("bob", true, |c| { - c.0.settings - .custom_proposal_types - .push(ProposalType::SELF_REMOVE) - }) + let carol_client = TestClientBuilder::new_for_test() + .with_random_signing_identity("carol", TEST_CIPHER_SUITE) + .await + .custom_proposal_type(ProposalType::SERVER_REMOVE) + .build(); + + // Alice adds Carol. + let commit = alice + .commit_builder() + .add_member( + carol_client + .generate_key_package_message(Default::default(), Default::default()) + .await + .unwrap(), + ) + .unwrap() + .build() + .await + .unwrap(); + let mut carol = carol_client + .join_group(None, &commit.welcome_messages[0]) + .await + .unwrap() + .0; + alice + .process_incoming_message(commit.commit_message.clone()) + .await + .unwrap(); + bob.process_incoming_message(commit.commit_message) .await .unwrap(); - (alice, bob) + let bob_server_removes_self = bob.propose_server_remove(1, Vec::new()).await.unwrap(); + let bob_server_removes_alice = bob.propose_server_remove(0, Vec::new()).await.unwrap(); + + let group_info = alice + .group_info_message_allowing_ext_commit(true) + .await + .unwrap(); + alice + .process_incoming_message(bob_server_removes_self.clone()) + .await + .unwrap(); + alice + .process_incoming_message(bob_server_removes_alice.clone()) + .await + .unwrap(); + carol + .process_incoming_message(bob_server_removes_self.clone()) + .await + .unwrap(); + carol + .process_incoming_message(bob_server_removes_alice.clone()) + .await + .unwrap(); + + let (carol_new_group, commit) = carol_client + .external_commit_builder() + .unwrap() + .with_removal(carol.current_member_index()) + .with_received_custom_proposal(bob_server_removes_self) + .with_received_custom_proposal(bob_server_removes_alice) + .build(group_info) + .await + .unwrap(); + + carol + .process_incoming_message(commit.clone()) + .await + .unwrap(); + bob.process_incoming_message(commit.clone()).await.unwrap(); + alice.process_incoming_message(commit).await.unwrap(); + + // Assert that after applying the commit removing Bob, that Bob and Alice are no longer in the group. + let carol_identity = carol_new_group.current_member_signing_identity().unwrap(); + let expected_member_identities = vec![carol_identity.clone()]; + itertools::assert_equal( + carol_new_group + .roster() + .members_iter() + .map(|m| m.signing_identity), + expected_member_identities.clone(), + ); + } + + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn external_commit_can_have_server_removes_by_value() { + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SERVER_REMOVE).await; + + let carol_client = TestClientBuilder::new_for_test() + .with_random_signing_identity("carol", TEST_CIPHER_SUITE) + .await + .custom_proposal_type(ProposalType::SERVER_REMOVE) + .build(); + + // Alice adds Carol. + let commit = alice + .commit_builder() + .add_member( + carol_client + .generate_key_package_message(Default::default(), Default::default()) + .await + .unwrap(), + ) + .unwrap() + .build() + .await + .unwrap(); + let mut carol = carol_client + .join_group(None, &commit.welcome_messages[0]) + .await + .unwrap() + .0; + alice + .process_incoming_message(commit.commit_message.clone()) + .await + .unwrap(); + bob.process_incoming_message(commit.commit_message) + .await + .unwrap(); + + let group_info = alice + .group_info_message_allowing_ext_commit(true) + .await + .unwrap(); + + let (carol_new_group, commit) = carol_client + .external_commit_builder() + .unwrap() + .with_removal(carol.current_member_index()) + .with_server_removal(1) // bob + .with_server_removal(0) // alice + .build(group_info) + .await + .unwrap(); + + carol + .process_incoming_message(commit.clone()) + .await + .unwrap(); + bob.process_incoming_message(commit.clone()).await.unwrap(); + alice.process_incoming_message(commit).await.unwrap(); + + // Assert that after applying the commit removing Bob, that Bob and Alice are no longer in the group. + let carol_identity = carol_new_group.current_member_signing_identity().unwrap(); + let expected_member_identities = vec![carol_identity.clone()]; + itertools::assert_equal( + carol_new_group + .roster() + .members_iter() + .map(|m| m.signing_identity), + expected_member_identities.clone(), + ); + } + + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn server_remove_self_with_resync_external_commit_fails() { + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SERVER_REMOVE).await; + + let carol_client = TestClientBuilder::new_for_test() + .with_random_signing_identity("carol", TEST_CIPHER_SUITE) + .await + .custom_proposal_type(ProposalType::SERVER_REMOVE) + .build(); + + // Alice adds Carol. + let commit = alice + .commit_builder() + .add_member( + carol_client + .generate_key_package_message(Default::default(), Default::default()) + .await + .unwrap(), + ) + .unwrap() + .build() + .await + .unwrap(); + let carol = carol_client + .join_group(None, &commit.welcome_messages[0]) + .await + .unwrap() + .0; + alice + .process_incoming_message(commit.commit_message.clone()) + .await + .unwrap(); + bob.process_incoming_message(commit.commit_message) + .await + .unwrap(); + + let group_info = alice + .group_info_message_allowing_ext_commit(true) + .await + .unwrap(); + + let carol_self_remove_with_server_kick = carol_client + .external_commit_builder() + .unwrap() + .with_removal(carol.current_member_index()) + .with_server_removal(2) // carol + .build(group_info) + .await; + + assert!(matches!( + carol_self_remove_with_server_kick, + Err(MlsError::RemovingNonExistingMember) + )); } #[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 (mut alice, mut bob) = custom_proposal_setup(TEST_CUSTOM_PROPOSAL_TYPE).await; let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![0, 1, 2]); @@ -4614,7 +4946,7 @@ mod tests { #[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 (mut alice, mut bob) = custom_proposal_setup(TEST_CUSTOM_PROPOSAL_TYPE).await; let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![0, 1, 2]); @@ -4868,7 +5200,7 @@ mod tests { ))] #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] async fn client_can_self_remove_and_another_client_can_commit() { - let (mut alice, mut bob) = self_remove_group_setup().await; + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SELF_REMOVE).await; let carol_client = TestClientBuilder::new_for_test() .with_random_signing_identity("carol", TEST_CIPHER_SUITE) @@ -4947,7 +5279,7 @@ mod tests { ))] #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] async fn commit_with_both_remove_and_self_remove_for_same_client_leaves_remove_unused() { - let (mut alice, mut bob) = self_remove_group_setup().await; + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SELF_REMOVE).await; // Bob proposes self-remove. let bob_self_remove = bob.propose_self_remove(Vec::new()).await.unwrap(); @@ -4983,7 +5315,7 @@ mod tests { ))] #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] async fn client_processing_commit_with_self_remove_without_processing_proposal_first_errors() { - let (mut alice, mut bob) = self_remove_group_setup().await; + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SELF_REMOVE).await; let carol_client = TestClientBuilder::new_for_test() .with_random_signing_identity("carol", TEST_CIPHER_SUITE) @@ -5046,7 +5378,7 @@ mod tests { ))] #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] async fn external_commit_can_have_self_remove() { - let (mut alice, mut bob) = self_remove_group_setup().await; + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SELF_REMOVE).await; let carol_client = TestClientBuilder::new_for_test() .with_random_signing_identity("carol", TEST_CIPHER_SUITE) @@ -5147,7 +5479,7 @@ mod tests { ))] #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] async fn can_build_external_commit_from_group_with_self_remove() { - let (mut alice, mut bob) = self_remove_group_setup().await; + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SELF_REMOVE).await; let carol_client = TestClientBuilder::new_for_test() .with_random_signing_identity("carol", TEST_CIPHER_SUITE) @@ -5221,7 +5553,7 @@ mod tests { ))] #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] async fn external_commit_can_have_multiple_self_removes() { - let (mut alice, mut bob) = self_remove_group_setup().await; + let (mut alice, mut bob) = custom_proposal_setup(ProposalType::SELF_REMOVE).await; let carol_client = TestClientBuilder::new_for_test() .with_random_signing_identity("carol", TEST_CIPHER_SUITE) diff --git a/mls-rs/src/group/proposal.rs b/mls-rs/src/group/proposal.rs index 61af176d..3cb292f7 100644 --- a/mls-rs/src/group/proposal.rs +++ b/mls-rs/src/group/proposal.rs @@ -244,6 +244,34 @@ impl Debug for ExternalInit { /// [`Group`](crate::group::Group). pub struct SelfRemoveProposal {} +#[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg(feature = "gsma_rcs_e2ee_feature")] +/// A proposal that the server remove an existing [`Member`](mls_rs_core::group::Member) of a +/// [`Group`](crate::group::Group). +pub struct ServerRemoveProposal { + pub(crate) to_remove: LeafIndex, +} + +#[cfg(feature = "gsma_rcs_e2ee_feature")] +impl ServerRemoveProposal { + /// The index of the [`Member`](mls_rs_core::group::Member) that will be removed by + /// this proposal. + pub fn to_remove(&self) -> u32 { + *self.to_remove + } +} + +#[cfg(feature = "gsma_rcs_e2ee_feature")] +impl From for ServerRemoveProposal { + fn from(value: u32) -> Self { + ServerRemoveProposal { + to_remove: LeafIndex(value), + } + } +} + #[cfg(feature = "custom_proposal")] #[derive(Clone, PartialEq, Eq)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] @@ -350,6 +378,8 @@ pub enum Proposal { feature = "self_remove_proposal" ))] SelfRemove(SelfRemoveProposal), + #[cfg(feature = "gsma_rcs_e2ee_feature")] + ServerRemove(ServerRemoveProposal), #[cfg(feature = "custom_proposal")] Custom(CustomProposal), } @@ -372,6 +402,8 @@ impl MlsSize for Proposal { feature = "self_remove_proposal" ))] Proposal::SelfRemove(p) => p.mls_encoded_len(), + #[cfg(feature = "gsma_rcs_e2ee_feature")] + Proposal::ServerRemove(p) => p.mls_encoded_len(), #[cfg(feature = "custom_proposal")] Proposal::Custom(p) => mls_rs_codec::byte_vec::mls_encoded_len(&p.data), }; @@ -400,6 +432,8 @@ impl MlsEncode for Proposal { feature = "self_remove_proposal" ))] Proposal::SelfRemove(p) => p.mls_encode(writer), + #[cfg(feature = "gsma_rcs_e2ee_feature")] + Proposal::ServerRemove(p) => p.mls_encode(writer), #[cfg(feature = "custom_proposal")] Proposal::Custom(p) => { if p.proposal_type.raw_value() <= 7 { @@ -445,6 +479,10 @@ impl MlsDecode for Proposal { ProposalType::SELF_REMOVE => { Proposal::SelfRemove(SelfRemoveProposal::mls_decode(reader)?) } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + ProposalType::SERVER_REMOVE => { + Proposal::ServerRemove(ServerRemoveProposal::mls_decode(reader)?) + } #[cfg(feature = "custom_proposal")] custom => Proposal::Custom(CustomProposal { proposal_type: custom, @@ -475,6 +513,8 @@ impl Proposal { feature = "self_remove_proposal" ))] Proposal::SelfRemove(_) => ProposalType::SELF_REMOVE, + #[cfg(feature = "gsma_rcs_e2ee_feature")] + Proposal::ServerRemove(_) => ProposalType::SERVER_REMOVE, #[cfg(feature = "custom_proposal")] Proposal::Custom(c) => c.proposal_type, } @@ -499,6 +539,8 @@ pub enum BorrowedProposal<'a> { feature = "self_remove_proposal" ))] SelfRemove(&'a SelfRemoveProposal), + #[cfg(feature = "gsma_rcs_e2ee_feature")] + ServerRemove(&'a ServerRemoveProposal), #[cfg(feature = "custom_proposal")] Custom(&'a CustomProposal), } @@ -523,6 +565,10 @@ impl<'a> From> for Proposal { feature = "self_remove_proposal" ))] BorrowedProposal::SelfRemove(self_remove) => Proposal::SelfRemove(self_remove.clone()), + #[cfg(feature = "gsma_rcs_e2ee_feature")] + BorrowedProposal::ServerRemove(server_remove) => { + Proposal::ServerRemove(server_remove.clone()) + } #[cfg(feature = "custom_proposal")] BorrowedProposal::Custom(custom) => Proposal::Custom(custom.clone()), } @@ -547,6 +593,8 @@ impl BorrowedProposal<'_> { feature = "self_remove_proposal" ))] BorrowedProposal::SelfRemove(_) => ProposalType::SELF_REMOVE, + #[cfg(feature = "gsma_rcs_e2ee_feature")] + BorrowedProposal::ServerRemove(_) => ProposalType::SERVER_REMOVE, #[cfg(feature = "custom_proposal")] BorrowedProposal::Custom(c) => c.proposal_type, } @@ -571,6 +619,8 @@ impl<'a> From<&'a Proposal> for BorrowedProposal<'a> { feature = "self_remove_proposal" ))] Proposal::SelfRemove(p) => BorrowedProposal::SelfRemove(p), + #[cfg(feature = "gsma_rcs_e2ee_feature")] + Proposal::ServerRemove(p) => BorrowedProposal::ServerRemove(p), #[cfg(feature = "custom_proposal")] Proposal::Custom(p) => BorrowedProposal::Custom(p), } @@ -632,6 +682,13 @@ impl<'a> From<&'a SelfRemoveProposal> for BorrowedProposal<'a> { } } +#[cfg(feature = "gsma_rcs_e2ee_feature")] +impl<'a> From<&'a ServerRemoveProposal> for BorrowedProposal<'a> { + fn from(p: &'a ServerRemoveProposal) -> Self { + Self::ServerRemove(p) + } +} + #[cfg(feature = "custom_proposal")] impl<'a> From<&'a CustomProposal> for BorrowedProposal<'a> { fn from(p: &'a CustomProposal) -> Self { diff --git a/mls-rs/src/group/proposal_filter/bundle.rs b/mls-rs/src/group/proposal_filter/bundle.rs index ca7114c4..a655e066 100644 --- a/mls-rs/src/group/proposal_filter/bundle.rs +++ b/mls-rs/src/group/proposal_filter/bundle.rs @@ -23,6 +23,8 @@ use crate::{ feature = "self_remove_proposal" ))] use crate::group::SelfRemoveProposal; +#[cfg(feature = "gsma_rcs_e2ee_feature")] +use crate::group::ServerRemoveProposal; #[cfg(feature = "by_ref_proposal")] use crate::group::{proposal_cache::CachedProposal, LeafIndex, ProposalRef, UpdateProposal}; @@ -58,6 +60,8 @@ pub struct ProposalBundle { feature = "self_remove_proposal" ))] pub(crate) self_removes: Vec>, + #[cfg(feature = "gsma_rcs_e2ee_feature")] + pub(crate) server_removes: Vec>, #[cfg(feature = "custom_proposal")] pub(crate) custom_proposals: Vec>, } @@ -114,6 +118,12 @@ impl ProposalBundle { sender, source, }), + #[cfg(feature = "gsma_rcs_e2ee_feature")] + Proposal::ServerRemove(proposal) => self.server_removes.push(ProposalInfo { + proposal, + sender, + source, + }), #[cfg(feature = "custom_proposal")] Proposal::Custom(proposal) => self.custom_proposals.push(ProposalInfo { proposal, @@ -247,6 +257,9 @@ impl ProposalBundle { #[cfg(feature = "by_ref_proposal")] let len = len + self.updates.len(); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let len = len + self.server_removes.len(); + len + self.additions.len() + self.removals.len() + self.reinitializations.len() @@ -280,6 +293,13 @@ impl ProposalBundle { .map(|p| p.as_ref().map(BorrowedProposal::SelfRemove)), ); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let res = res.chain( + self.server_removes + .iter() + .map(|p| p.as_ref().map(BorrowedProposal::ServerRemove)), + ); + #[cfg(feature = "by_ref_proposal")] let res = res.chain( self.updates @@ -355,6 +375,13 @@ impl ProposalBundle { .map(|p| p.map(Proposal::SelfRemove)), ); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let res = res.chain( + self.server_removes + .into_iter() + .map(|p| p.map(Proposal::ServerRemove)), + ); + res.chain( self.additions .into_iter() @@ -436,6 +463,12 @@ impl ProposalBundle { &self.self_removes } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)] + pub fn server_remove_proposals(&self) -> &[ProposalInfo] { + &self.server_removes + } + /// Custom proposals in the bundle. #[cfg(feature = "custom_proposal")] pub fn custom_proposals(&self) -> &[ProposalInfo] { @@ -733,6 +766,8 @@ impl_proposable!(ExternalInit, EXTERNAL_INIT, external_initializations); feature = "self_remove_proposal" ))] impl_proposable!(SelfRemoveProposal, SELF_REMOVE, self_removes); +#[cfg(feature = "gsma_rcs_e2ee_feature")] +impl_proposable!(ServerRemoveProposal, SERVER_REMOVE, server_removes); impl_proposable!( ExtensionList, GROUP_CONTEXT_EXTENSIONS, diff --git a/mls-rs/src/group/proposal_filter/filtering.rs b/mls-rs/src/group/proposal_filter/filtering.rs index c7c89c05..96276fff 100644 --- a/mls-rs/src/group/proposal_filter/filtering.rs +++ b/mls-rs/src/group/proposal_filter/filtering.rs @@ -35,6 +35,8 @@ use crate::extension::ExternalSendersExt; feature = "self_remove_proposal" ))] use crate::group::SelfRemoveProposal; +#[cfg(feature = "gsma_rcs_e2ee_feature")] +use crate::group::ServerRemoveProposal; use alloc::vec::Vec; use mls_rs_core::{ @@ -120,6 +122,9 @@ where ))] let proposals = filter_out_remove_if_self_remove_same_leaf(strategy, proposals)?; + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let proposals = filter_out_server_remove_if_remove_same_leaf(strategy, proposals)?; + self.apply_proposal_changes(strategy, proposals, commit_time) .await } @@ -342,6 +347,39 @@ fn filter_out_remove_if_self_remove_same_leaf( .ok_or(MlsError::CommitterSelfRemoval), ) })?; + + #[cfg(feature = "gsma_rcs_e2ee_feature")] + proposals.retain_by_type::(|p| { + apply_strategy( + strategy, + p.is_by_reference(), + (!self_removed_leaves.contains(&Some(p.proposal.to_remove.0))) + .then_some(()) + .ok_or(MlsError::CommitterSelfRemoval), + ) + })?; + Ok(proposals) +} + +#[cfg(feature = "gsma_rcs_e2ee_feature")] +fn filter_out_server_remove_if_remove_same_leaf( + strategy: FilterStrategy, + mut proposals: ProposalBundle, +) -> Result { + let removed_leaves: Vec = proposals + .by_type::() + .map(|p| p.proposal.to_remove.0) + .collect(); + + proposals.retain_by_type::(|p| { + apply_strategy( + strategy, + p.is_by_reference(), + (!removed_leaves.contains(&(p.proposal.to_remove.0))) + .then_some(()) + .ok_or(MlsError::CommitterSelfRemoval), + ) + })?; Ok(proposals) } @@ -393,6 +431,16 @@ fn filter_out_removal_of_committer( .ok_or(MlsError::CommitterSelfRemoval), ) })?; + #[cfg(feature = "gsma_rcs_e2ee_feature")] + proposals.retain_by_type::(|p| { + apply_strategy( + strategy, + p.is_by_reference(), + (p.proposal.to_remove != commit_sender) + .then_some(()) + .ok_or(MlsError::CommitterSelfRemoval), + ) + })?; Ok(proposals) } @@ -520,14 +568,19 @@ pub(crate) fn proposer_can_propose( source: &ProposalSource, ) -> Result<(), MlsError> { let can_propose = match (proposer, source) { - (Sender::Member(_), ProposalSource::ByValue) => matches!( - proposal_type, - ProposalType::ADD - | ProposalType::REMOVE - | ProposalType::PSK - | ProposalType::RE_INIT - | ProposalType::GROUP_CONTEXT_EXTENSIONS - ), + (Sender::Member(_), ProposalSource::ByValue) => { + let can_propose = matches!( + proposal_type, + ProposalType::ADD + | ProposalType::REMOVE + | ProposalType::PSK + | ProposalType::RE_INIT + | ProposalType::GROUP_CONTEXT_EXTENSIONS + ); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let can_propose = can_propose || matches!(proposal_type, ProposalType::SERVER_REMOVE); + can_propose + } (Sender::Member(_), ProposalSource::Local) => { let can_propose = matches!( proposal_type, @@ -543,6 +596,8 @@ pub(crate) fn proposer_can_propose( feature = "self_remove_proposal" ))] let can_propose = can_propose || matches!(proposal_type, ProposalType::SELF_REMOVE); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let can_propose = can_propose || matches!(proposal_type, ProposalType::SERVER_REMOVE); can_propose } (Sender::Member(_), ProposalSource::ByReference(_)) => { @@ -561,23 +616,35 @@ pub(crate) fn proposer_can_propose( feature = "self_remove_proposal" ))] let can_propose = can_propose || matches!(proposal_type, ProposalType::SELF_REMOVE); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let can_propose = can_propose || matches!(proposal_type, ProposalType::SERVER_REMOVE); can_propose } #[cfg(feature = "by_ref_proposal")] (Sender::External(_), ProposalSource::ByValue) => false, #[cfg(feature = "by_ref_proposal")] - (Sender::External(_), _) => matches!( - proposal_type, - ProposalType::ADD - | ProposalType::REMOVE - | ProposalType::RE_INIT - | ProposalType::PSK - | ProposalType::GROUP_CONTEXT_EXTENSIONS - ), - (Sender::NewMemberCommit, ProposalSource::ByValue | ProposalSource::Local) => matches!( - proposal_type, - ProposalType::REMOVE | ProposalType::PSK | ProposalType::EXTERNAL_INIT - ), + (Sender::External(_), _) => { + let can_propose = matches!( + proposal_type, + ProposalType::ADD + | ProposalType::REMOVE + | ProposalType::RE_INIT + | ProposalType::PSK + | ProposalType::GROUP_CONTEXT_EXTENSIONS + ); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let can_propose = can_propose || matches!(proposal_type, ProposalType::SERVER_REMOVE); + can_propose + } + (Sender::NewMemberCommit, ProposalSource::ByValue | ProposalSource::Local) => { + let can_propose = matches!( + proposal_type, + ProposalType::REMOVE | ProposalType::PSK | ProposalType::EXTERNAL_INIT + ); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let can_propose = can_propose || matches!(proposal_type, ProposalType::SERVER_REMOVE); + can_propose + } (Sender::NewMemberCommit, ProposalSource::ByReference(_)) => false, (Sender::NewMemberProposal, ProposalSource::ByValue | ProposalSource::Local) => false, (Sender::NewMemberProposal, ProposalSource::ByReference(_)) => { @@ -636,6 +703,16 @@ pub(crate) fn filter_out_invalid_proposers( } } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + for i in (0..proposals.server_remove_proposals().len()).rev() { + let p = &proposals.server_remove_proposals()[i]; + let res = proposer_can_propose(p.sender, ProposalType::SERVER_REMOVE, &p.source); + + if !apply_strategy(strategy, p.is_by_reference(), res)? { + proposals.remove::(i); + } + } + #[cfg(feature = "psk")] for i in (0..proposals.psk_proposals().len()).rev() { let p = &proposals.psk_proposals()[i]; diff --git a/mls-rs/src/group/proposal_filter/filtering_common.rs b/mls-rs/src/group/proposal_filter/filtering_common.rs index fda588f1..503b9c29 100644 --- a/mls-rs/src/group/proposal_filter/filtering_common.rs +++ b/mls-rs/src/group/proposal_filter/filtering_common.rs @@ -508,6 +508,8 @@ fn ensure_proposals_in_external_commit_are_allowed( feature = "self_remove_proposal" ))] ProposalType::SELF_REMOVE, + #[cfg(feature = "gsma_rcs_e2ee_feature")] + ProposalType::SERVER_REMOVE, ]; let unsupported_proposal = proposals.iter_proposals().find(|proposal| { diff --git a/mls-rs/src/group/test_utils.rs b/mls-rs/src/group/test_utils.rs index c187897c..46349ad9 100644 --- a/mls-rs/src/group/test_utils.rs +++ b/mls-rs/src/group/test_utils.rs @@ -491,6 +491,15 @@ impl MessageProcessor for GroupWithoutKeySchedule { self.inner.self_removal_proposal(provisional_state) } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)] + fn server_removal_proposal( + &self, + provisional_state: &ProvisionalState, + ) -> Option> { + self.inner.server_removal_proposal(provisional_state) + } + #[cfg(feature = "private_message")] #[cfg_attr(coverage_nightly, coverage(off))] fn min_epoch_available(&self) -> Option { diff --git a/mls-rs/src/tree_kem/mod.rs b/mls-rs/src/tree_kem/mod.rs index 80877fc1..480a3704 100644 --- a/mls-rs/src/tree_kem/mod.rs +++ b/mls-rs/src/tree_kem/mod.rs @@ -32,6 +32,8 @@ use crate::group::proposal::{AddProposal, UpdateProposal}; feature = "self_remove_proposal" ))] use crate::group::proposal::SelfRemoveProposal; +#[cfg(feature = "gsma_rcs_e2ee_feature")] +use crate::group::proposal::ServerRemoveProposal; #[cfg(any(test, feature = "by_ref_proposal"))] use crate::group::{proposal::RemoveProposal, proposal_filter::bundle::Proposable}; @@ -427,6 +429,26 @@ impl TreeKemPublic { .await?; } + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let mut server_removed = vec![]; + #[cfg(feature = "gsma_rcs_e2ee_feature")] + for i in (0..proposal_bundle.server_remove_proposals().len()).rev() { + let index = proposal_bundle.server_remove_proposals()[i] + .proposal + .to_remove; + server_removed.push(index); + self.apply_remove::( + index, + i, + proposal_bundle.server_remove_proposals()[i].is_by_value(), + proposal_bundle, + extensions, + id_provider, + filter, + ) + .await?; + } + // Remove from the tree old leaves from updates let mut partial_updates = vec![]; let senders = proposal_bundle.update_senders.iter().copied(); @@ -568,6 +590,9 @@ impl TreeKemPublic { #[cfg(all(feature = "custom_proposal", feature = "self_remove_proposal"))] let chained = chained.chain(self_removed); + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let chained = chained.chain(server_removed); + let updated_leaves = chained.collect_vec(); self.update_hashes(&updated_leaves, cipher_suite_provider) @@ -576,6 +601,35 @@ impl TreeKemPublic { Ok(added) } + #[cfg(not(feature = "by_ref_proposal"))] + #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] + async fn apply_remove_lite( + &mut self, + index: LeafIndex, + _extensions: &ExtensionList, + _id_provider: &I, + ) -> Result<(), MlsError> + where + I: IdentityProvider, + { + #[cfg(feature = "tree_index")] + { + // If this fails, it's not because the proposal is bad. + let old_leaf = self.nodes.blank_leaf_node(index)?; + + let identity = identity(&old_leaf.signing_identity, _id_provider, _extensions).await?; + + self.index.remove(&old_leaf, &identity); + } + + #[cfg(not(feature = "tree_index"))] + self.nodes.blank_leaf_node(index)?; + + self.nodes.blank_direct_path(index)?; + + Ok(()) + } + #[cfg(not(feature = "by_ref_proposal"))] #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] pub async fn batch_edit_lite( @@ -593,21 +647,16 @@ impl TreeKemPublic { for p in &proposal_bundle.removals { let index = p.proposal.to_remove; - #[cfg(feature = "tree_index")] - { - // If this fails, it's not because the proposal is bad. - let old_leaf = self.nodes.blank_leaf_node(index)?; - - let identity = - identity(&old_leaf.signing_identity, id_provider, extensions).await?; - - self.index.remove(&old_leaf, &identity); - } + self.apply_remove_lite(index, extensions, id_provider) + .await?; + } - #[cfg(not(feature = "tree_index"))] - self.nodes.blank_leaf_node(index)?; + #[cfg(feature = "gsma_rcs_e2ee_feature")] + for p in &proposal_bundle.server_removes { + let index = p.proposal.to_remove; - self.nodes.blank_direct_path(index)?; + self.apply_remove_lite(index, extensions, id_provider) + .await?; } // Apply adds @@ -624,12 +673,21 @@ impl TreeKemPublic { self.nodes.trim(); - let updated_leaves = proposal_bundle + let chained = proposal_bundle .remove_proposals() .iter() .map(|p| p.proposal.to_remove) - .chain(added.iter().copied()) - .collect_vec(); + .chain(added.iter().copied()); + + #[cfg(feature = "gsma_rcs_e2ee_feature")] + let chained = chained.chain( + proposal_bundle + .server_remove_proposals() + .iter() + .map(|p| p.proposal.to_remove), + ); + + let updated_leaves = chained.collect_vec(); self.update_hashes(&updated_leaves, cipher_suite_provider) .await?;