core_crypto/mls/conversation/conversation_guard/
merge.rs

1//! A MLS group is a distributed object scattered across many parties. We use a Delivery Service
2//! to orchestrate those parties. So when we create a commit, a mutable operation, it has to be
3//! validated by the Delivery Service. But it might occur that another group member did the
4//! exact same thing at the same time. So if we arrive second in this race, we must "rollback" the commit
5//! we created and accept ("merge") the other one.
6//! A client would
7//! * Create a commit
8//! * Send the commit to the Delivery Service
9//! * When Delivery Service responds
10//!     * 200 OK --> use [TransactionContext::commit_accepted] to merge the commit
11//!     * 409 CONFLICT --> do nothing. [ConversationGuard::decrypt_message] will restore the proposals not committed
12//!     * 5xx --> retry
13
14use openmls::prelude::MlsGroupStateError;
15
16use super::{ConversationGuard, Result};
17use crate::{
18    MlsError,
19    mls::conversation::{ConversationWithMls as _, Error},
20    prelude::{MlsProposalRef, Obfuscated},
21};
22
23impl ConversationGuard {
24    /// Allows to remove a pending (uncommitted) proposal. Use this when backend rejects the proposal
25    /// you just sent e.g. if permissions have changed meanwhile.
26    ///
27    /// **CAUTION**: only use this when you had an explicit response from the Delivery Service
28    /// e.g. 403 or 409. Do not use otherwise e.g. 5xx responses, timeout etc..
29    ///
30    /// # Arguments
31    /// * `conversation_id` - the group/conversation id
32    /// * `proposal_ref` - unique proposal identifier which is present in [crate::prelude::MlsProposalBundle]
33    ///   and returned from all operation creating a proposal
34    ///
35    /// # Errors
36    /// When the conversation is not found or the proposal reference does not identify a proposal
37    /// in the local pending proposal store
38    pub async fn clear_pending_proposal(&mut self, proposal_ref: MlsProposalRef) -> Result<()> {
39        let keystore = self.crypto_provider().await?.keystore();
40        let mut conversation = self.conversation_mut().await;
41        conversation
42            .group
43            .remove_pending_proposal(&keystore, &proposal_ref)
44            .await
45            .map_err(|mls_group_state_error| match mls_group_state_error {
46                MlsGroupStateError::PendingProposalNotFound => Error::PendingProposalNotFound(proposal_ref),
47                _ => MlsError::wrap("removing pending proposal")(mls_group_state_error).into(),
48            })?;
49        conversation.persist_group_when_changed(&keystore, true).await?;
50        Ok(())
51    }
52
53    /// Allows to remove a pending commit. Use this when backend rejects the commit
54    /// you just sent e.g. if permissions have changed meanwhile.
55    ///
56    /// **CAUTION**: only use this when you had an explicit response from the Delivery Service
57    /// e.g. 403. Do not use otherwise e.g. 5xx responses, timeout etc..
58    /// **DO NOT** use when Delivery Service responds 409, pending state will be renewed
59    /// in [ConversationGuard::decrypt_message]
60    ///
61    ///
62    /// # Errors
63    /// When there is no pending commit
64    pub(crate) async fn clear_pending_commit(&mut self) -> Result<()> {
65        let keystore = self.crypto_provider().await?.keystore();
66        let mut conversation = self.conversation_mut().await;
67        if conversation.group.pending_commit().is_some() {
68            conversation.group.clear_pending_commit();
69            conversation.persist_group_when_changed(&keystore, true).await?;
70            log::info!(group_id = Obfuscated::from(conversation.id()); "Cleared pending commit.");
71            Ok(())
72        } else {
73            Err(Error::PendingCommitNotFound)
74        }
75    }
76
77    /// Clear a pending commit if it exists. Unlike [Self::clear_pending_commit],
78    /// don't throw an error if there is none.
79    pub(crate) async fn ensure_no_pending_commit(&mut self) -> Result<()> {
80        match self.clear_pending_commit().await {
81            Err(Error::PendingCommitNotFound) => Ok(()),
82            result => result,
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use openmls::prelude::Proposal;
90    use wasm_bindgen_test::*;
91
92    use crate::test_utils::*;
93
94    use super::*;
95
96    wasm_bindgen_test_configure!(run_in_browser);
97
98    mod clear_pending_proposal {
99        use super::*;
100
101        #[apply(all_cred_cipher)]
102        #[wasm_bindgen_test]
103        pub async fn should_remove_proposal(case: TestContext) {
104            let [mut alice_central, bob_central, charlie_central] = case.sessions().await;
105            Box::pin(async move {
106                let id = conversation_id();
107                alice_central
108                    .transaction
109                    .new_conversation(&id, case.credential_type, case.cfg.clone())
110                    .await
111                    .unwrap();
112                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
113                assert!(alice_central.pending_proposals(&id).await.is_empty());
114
115                let charlie_kp = charlie_central.get_one_key_package(&case).await;
116                let add_ref = alice_central
117                    .transaction
118                    .new_add_proposal(&id, charlie_kp)
119                    .await
120                    .unwrap()
121                    .proposal_ref;
122
123                let remove_ref = alice_central
124                    .transaction
125                    .new_remove_proposal(&id, bob_central.get_client_id().await)
126                    .await
127                    .unwrap()
128                    .proposal_ref;
129
130                let update_ref = alice_central
131                    .transaction
132                    .new_update_proposal(&id)
133                    .await
134                    .unwrap()
135                    .proposal_ref;
136
137                assert_eq!(alice_central.pending_proposals(&id).await.len(), 3);
138                alice_central
139                    .transaction
140                    .conversation(&id)
141                    .await
142                    .unwrap()
143                    .clear_pending_proposal(add_ref)
144                    .await
145                    .unwrap();
146                assert_eq!(alice_central.pending_proposals(&id).await.len(), 2);
147                assert!(
148                    !alice_central
149                        .pending_proposals(&id)
150                        .await
151                        .into_iter()
152                        .any(|p| matches!(p.proposal(), Proposal::Add(_)))
153                );
154
155                alice_central
156                    .transaction
157                    .conversation(&id)
158                    .await
159                    .unwrap()
160                    .clear_pending_proposal(remove_ref)
161                    .await
162                    .unwrap();
163                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
164                assert!(
165                    !alice_central
166                        .pending_proposals(&id)
167                        .await
168                        .into_iter()
169                        .any(|p| matches!(p.proposal(), Proposal::Remove(_)))
170                );
171
172                alice_central
173                    .transaction
174                    .conversation(&id)
175                    .await
176                    .unwrap()
177                    .clear_pending_proposal(update_ref)
178                    .await
179                    .unwrap();
180                assert!(alice_central.pending_proposals(&id).await.is_empty());
181                assert!(
182                    !alice_central
183                        .pending_proposals(&id)
184                        .await
185                        .into_iter()
186                        .any(|p| matches!(p.proposal(), Proposal::Update(_)))
187                );
188            })
189            .await
190        }
191
192        #[apply(all_cred_cipher)]
193        #[wasm_bindgen_test]
194        pub async fn should_fail_when_proposal_ref_not_found(case: TestContext) {
195            let [mut alice_central] = case.sessions().await;
196            Box::pin(async move {
197                let id = conversation_id();
198                alice_central
199                    .transaction
200                    .new_conversation(&id, case.credential_type, case.cfg.clone())
201                    .await
202                    .unwrap();
203                assert!(alice_central.pending_proposals(&id).await.is_empty());
204                let any_ref = MlsProposalRef::from(vec![0; case.ciphersuite().hash_length()]);
205                let clear = alice_central
206                    .transaction
207                    .conversation(&id)
208                    .await
209                    .unwrap()
210                    .clear_pending_proposal(any_ref.clone())
211                    .await;
212                assert!(matches!(clear.unwrap_err(), Error::PendingProposalNotFound(prop_ref) if prop_ref == any_ref))
213            })
214            .await
215        }
216
217        #[apply(all_cred_cipher)]
218        #[wasm_bindgen_test]
219        pub async fn should_clean_associated_key_material(case: TestContext) {
220            let [mut cc] = case.sessions().await;
221            Box::pin(async move {
222                let id = conversation_id();
223                cc.transaction
224                    .new_conversation(&id, case.credential_type, case.cfg.clone())
225                    .await
226                    .unwrap();
227                assert!(cc.pending_proposals(&id).await.is_empty());
228
229                let init = cc.transaction.count_entities().await;
230
231                let proposal_ref = cc.transaction.new_update_proposal(&id).await.unwrap().proposal_ref;
232                assert_eq!(cc.pending_proposals(&id).await.len(), 1);
233
234                cc.transaction
235                    .conversation(&id)
236                    .await
237                    .unwrap()
238                    .clear_pending_proposal(proposal_ref)
239                    .await
240                    .unwrap();
241                assert!(cc.pending_proposals(&id).await.is_empty());
242
243                // This whole flow should be idempotent.
244                // Here we verify that we are indeed deleting the `EncryptionKeyPair` created
245                // for the Update proposal
246                let after_clear_proposal = cc.transaction.count_entities().await;
247                assert_eq!(init, after_clear_proposal);
248            })
249            .await
250        }
251    }
252
253    mod clear_pending_commit {
254        use super::*;
255
256        #[apply(all_cred_cipher)]
257        #[wasm_bindgen_test]
258        pub async fn should_remove_commit(case: TestContext) {
259            let [alice_central] = case.sessions().await;
260            Box::pin(async move {
261                let id = conversation_id();
262                alice_central
263                    .transaction
264                    .new_conversation(&id, case.credential_type, case.cfg.clone())
265                    .await
266                    .unwrap();
267                assert!(alice_central.pending_commit(&id).await.is_none());
268
269                alice_central.create_unmerged_commit(&id).await;
270                assert!(alice_central.pending_commit(&id).await.is_some());
271                alice_central
272                    .transaction
273                    .conversation(&id)
274                    .await
275                    .unwrap()
276                    .clear_pending_commit()
277                    .await
278                    .unwrap();
279                assert!(alice_central.pending_commit(&id).await.is_none());
280            })
281            .await
282        }
283
284        #[apply(all_cred_cipher)]
285        #[wasm_bindgen_test]
286        pub async fn should_fail_when_pending_commit_absent(case: TestContext) {
287            let [alice_central] = case.sessions().await;
288            Box::pin(async move {
289                let id = conversation_id();
290                alice_central
291                    .transaction
292                    .new_conversation(&id, case.credential_type, case.cfg.clone())
293                    .await
294                    .unwrap();
295                assert!(alice_central.pending_commit(&id).await.is_none());
296                let clear = alice_central
297                    .transaction
298                    .conversation(&id)
299                    .await
300                    .unwrap()
301                    .clear_pending_commit()
302                    .await;
303                assert!(matches!(clear.unwrap_err(), Error::PendingCommitNotFound))
304            })
305            .await
306        }
307
308        #[apply(all_cred_cipher)]
309        #[wasm_bindgen_test]
310        pub async fn should_clean_associated_key_material(case: TestContext) {
311            let [cc] = case.sessions().await;
312            Box::pin(async move {
313                let id = conversation_id();
314                cc.transaction
315                    .new_conversation(&id, case.credential_type, case.cfg.clone())
316                    .await
317                    .unwrap();
318                assert!(cc.pending_commit(&id).await.is_none());
319
320                let init = cc.transaction.count_entities().await;
321
322                cc.create_unmerged_commit(&id).await;
323                assert!(cc.pending_commit(&id).await.is_some());
324
325                cc.transaction
326                    .conversation(&id)
327                    .await
328                    .unwrap()
329                    .clear_pending_commit()
330                    .await
331                    .unwrap();
332                assert!(cc.pending_commit(&id).await.is_none());
333
334                // This whole flow should be idempotent.
335                // Here we verify that we are indeed deleting the `EncryptionKeyPair` created
336                // for the Update commit
337                let after_clear_commit = cc.transaction.count_entities().await;
338                assert_eq!(init, after_clear_commit);
339            })
340            .await
341        }
342    }
343}