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            run_test_with_client_ids(
105                case.clone(),
106                ["alice", "bob", "charlie"],
107                move |[mut alice_central, bob_central, charlie_central]| {
108                    Box::pin(async move {
109                        let id = conversation_id();
110                        alice_central
111                            .transaction
112                            .new_conversation(&id, case.credential_type, case.cfg.clone())
113                            .await
114                            .unwrap();
115                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
116                        assert!(alice_central.pending_proposals(&id).await.is_empty());
117
118                        let charlie_kp = charlie_central.get_one_key_package(&case).await;
119                        let add_ref = alice_central
120                            .transaction
121                            .new_add_proposal(&id, charlie_kp)
122                            .await
123                            .unwrap()
124                            .proposal_ref;
125
126                        let remove_ref = alice_central
127                            .transaction
128                            .new_remove_proposal(&id, bob_central.get_client_id().await)
129                            .await
130                            .unwrap()
131                            .proposal_ref;
132
133                        let update_ref = alice_central
134                            .transaction
135                            .new_update_proposal(&id)
136                            .await
137                            .unwrap()
138                            .proposal_ref;
139
140                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 3);
141                        alice_central
142                            .transaction
143                            .conversation(&id)
144                            .await
145                            .unwrap()
146                            .clear_pending_proposal(add_ref)
147                            .await
148                            .unwrap();
149                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 2);
150                        assert!(
151                            !alice_central
152                                .pending_proposals(&id)
153                                .await
154                                .into_iter()
155                                .any(|p| matches!(p.proposal(), Proposal::Add(_)))
156                        );
157
158                        alice_central
159                            .transaction
160                            .conversation(&id)
161                            .await
162                            .unwrap()
163                            .clear_pending_proposal(remove_ref)
164                            .await
165                            .unwrap();
166                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
167                        assert!(
168                            !alice_central
169                                .pending_proposals(&id)
170                                .await
171                                .into_iter()
172                                .any(|p| matches!(p.proposal(), Proposal::Remove(_)))
173                        );
174
175                        alice_central
176                            .transaction
177                            .conversation(&id)
178                            .await
179                            .unwrap()
180                            .clear_pending_proposal(update_ref)
181                            .await
182                            .unwrap();
183                        assert!(alice_central.pending_proposals(&id).await.is_empty());
184                        assert!(
185                            !alice_central
186                                .pending_proposals(&id)
187                                .await
188                                .into_iter()
189                                .any(|p| matches!(p.proposal(), Proposal::Update(_)))
190                        );
191                    })
192                },
193            )
194            .await
195        }
196
197        #[apply(all_cred_cipher)]
198        #[wasm_bindgen_test]
199        pub async fn should_fail_when_proposal_ref_not_found(case: TestContext) {
200            run_test_with_client_ids(case.clone(), ["alice"], move |[mut alice_central]| {
201                Box::pin(async move {
202                    let id = conversation_id();
203                    alice_central
204                        .transaction
205                        .new_conversation(&id, case.credential_type, case.cfg.clone())
206                        .await
207                        .unwrap();
208                    assert!(alice_central.pending_proposals(&id).await.is_empty());
209                    let any_ref = MlsProposalRef::from(vec![0; case.ciphersuite().hash_length()]);
210                    let clear = alice_central
211                        .transaction
212                        .conversation(&id)
213                        .await
214                        .unwrap()
215                        .clear_pending_proposal(any_ref.clone())
216                        .await;
217                    assert!(
218                        matches!(clear.unwrap_err(), Error::PendingProposalNotFound(prop_ref) if prop_ref == any_ref)
219                    )
220                })
221            })
222            .await
223        }
224
225        #[apply(all_cred_cipher)]
226        #[wasm_bindgen_test]
227        pub async fn should_clean_associated_key_material(case: TestContext) {
228            run_test_with_client_ids(case.clone(), ["alice"], move |[mut cc]| {
229                Box::pin(async move {
230                    let id = conversation_id();
231                    cc.transaction
232                        .new_conversation(&id, case.credential_type, case.cfg.clone())
233                        .await
234                        .unwrap();
235                    assert!(cc.pending_proposals(&id).await.is_empty());
236
237                    let init = cc.transaction.count_entities().await;
238
239                    let proposal_ref = cc.transaction.new_update_proposal(&id).await.unwrap().proposal_ref;
240                    assert_eq!(cc.pending_proposals(&id).await.len(), 1);
241
242                    cc.transaction
243                        .conversation(&id)
244                        .await
245                        .unwrap()
246                        .clear_pending_proposal(proposal_ref)
247                        .await
248                        .unwrap();
249                    assert!(cc.pending_proposals(&id).await.is_empty());
250
251                    // This whole flow should be idempotent.
252                    // Here we verify that we are indeed deleting the `EncryptionKeyPair` created
253                    // for the Update proposal
254                    let after_clear_proposal = cc.transaction.count_entities().await;
255                    assert_eq!(init, after_clear_proposal);
256                })
257            })
258            .await
259        }
260    }
261
262    mod clear_pending_commit {
263        use super::*;
264
265        #[apply(all_cred_cipher)]
266        #[wasm_bindgen_test]
267        pub async fn should_remove_commit(case: TestContext) {
268            run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
269                Box::pin(async move {
270                    let id = conversation_id();
271                    alice_central
272                        .transaction
273                        .new_conversation(&id, case.credential_type, case.cfg.clone())
274                        .await
275                        .unwrap();
276                    assert!(alice_central.pending_commit(&id).await.is_none());
277
278                    alice_central.create_unmerged_commit(&id).await;
279                    assert!(alice_central.pending_commit(&id).await.is_some());
280                    alice_central
281                        .transaction
282                        .conversation(&id)
283                        .await
284                        .unwrap()
285                        .clear_pending_commit()
286                        .await
287                        .unwrap();
288                    assert!(alice_central.pending_commit(&id).await.is_none());
289                })
290            })
291            .await
292        }
293
294        #[apply(all_cred_cipher)]
295        #[wasm_bindgen_test]
296        pub async fn should_fail_when_pending_commit_absent(case: TestContext) {
297            run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
298                Box::pin(async move {
299                    let id = conversation_id();
300                    alice_central
301                        .transaction
302                        .new_conversation(&id, case.credential_type, case.cfg.clone())
303                        .await
304                        .unwrap();
305                    assert!(alice_central.pending_commit(&id).await.is_none());
306                    let clear = alice_central
307                        .transaction
308                        .conversation(&id)
309                        .await
310                        .unwrap()
311                        .clear_pending_commit()
312                        .await;
313                    assert!(matches!(clear.unwrap_err(), Error::PendingCommitNotFound))
314                })
315            })
316            .await
317        }
318
319        #[apply(all_cred_cipher)]
320        #[wasm_bindgen_test]
321        pub async fn should_clean_associated_key_material(case: TestContext) {
322            run_test_with_client_ids(case.clone(), ["alice"], move |[cc]| {
323                Box::pin(async move {
324                    let id = conversation_id();
325                    cc.transaction
326                        .new_conversation(&id, case.credential_type, case.cfg.clone())
327                        .await
328                        .unwrap();
329                    assert!(cc.pending_commit(&id).await.is_none());
330
331                    let init = cc.transaction.count_entities().await;
332
333                    cc.create_unmerged_commit(&id).await;
334                    assert!(cc.pending_commit(&id).await.is_some());
335
336                    cc.transaction
337                        .conversation(&id)
338                        .await
339                        .unwrap()
340                        .clear_pending_commit()
341                        .await
342                        .unwrap();
343                    assert!(cc.pending_commit(&id).await.is_none());
344
345                    // This whole flow should be idempotent.
346                    // Here we verify that we are indeed deleting the `EncryptionKeyPair` created
347                    // for the Update commit
348                    let after_clear_commit = cc.transaction.count_entities().await;
349                    assert_eq!(init, after_clear_commit);
350                })
351            })
352            .await
353        }
354    }
355}