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,
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    /// # Arguments
62    /// * `conversation_id` - the group/conversation id
63    ///
64    /// # Errors
65    /// When the conversation is not found or there is no pending commit
66    pub(crate) async fn clear_pending_commit(&mut self) -> Result<()> {
67        let keystore = self.crypto_provider().await?.keystore();
68        let mut conversation = self.conversation_mut().await;
69        if conversation.group.pending_commit().is_some() {
70            conversation.group.clear_pending_commit();
71            conversation.persist_group_when_changed(&keystore, true).await?;
72            Ok(())
73        } else {
74            Err(Error::PendingCommitNotFound)
75        }
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use openmls::prelude::Proposal;
82    use wasm_bindgen_test::*;
83
84    use crate::test_utils::*;
85
86    use super::*;
87
88    wasm_bindgen_test_configure!(run_in_browser);
89
90    mod clear_pending_proposal {
91        use super::*;
92
93        #[apply(all_cred_cipher)]
94        #[wasm_bindgen_test]
95        pub async fn should_remove_proposal(case: TestCase) {
96            run_test_with_client_ids(
97                case.clone(),
98                ["alice", "bob", "charlie"],
99                move |[mut alice_central, bob_central, charlie_central]| {
100                    Box::pin(async move {
101                        let id = conversation_id();
102                        alice_central
103                            .context
104                            .new_conversation(&id, case.credential_type, case.cfg.clone())
105                            .await
106                            .unwrap();
107                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
108                        assert!(alice_central.pending_proposals(&id).await.is_empty());
109
110                        let charlie_kp = charlie_central.get_one_key_package(&case).await;
111                        let add_ref = alice_central
112                            .context
113                            .new_add_proposal(&id, charlie_kp)
114                            .await
115                            .unwrap()
116                            .proposal_ref;
117
118                        let remove_ref = alice_central
119                            .context
120                            .new_remove_proposal(&id, bob_central.get_client_id().await)
121                            .await
122                            .unwrap()
123                            .proposal_ref;
124
125                        let update_ref = alice_central
126                            .context
127                            .new_update_proposal(&id)
128                            .await
129                            .unwrap()
130                            .proposal_ref;
131
132                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 3);
133                        alice_central
134                            .context
135                            .conversation(&id)
136                            .await
137                            .unwrap()
138                            .clear_pending_proposal(add_ref)
139                            .await
140                            .unwrap();
141                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 2);
142                        assert!(
143                            !alice_central
144                                .pending_proposals(&id)
145                                .await
146                                .into_iter()
147                                .any(|p| matches!(p.proposal(), Proposal::Add(_)))
148                        );
149
150                        alice_central
151                            .context
152                            .conversation(&id)
153                            .await
154                            .unwrap()
155                            .clear_pending_proposal(remove_ref)
156                            .await
157                            .unwrap();
158                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
159                        assert!(
160                            !alice_central
161                                .pending_proposals(&id)
162                                .await
163                                .into_iter()
164                                .any(|p| matches!(p.proposal(), Proposal::Remove(_)))
165                        );
166
167                        alice_central
168                            .context
169                            .conversation(&id)
170                            .await
171                            .unwrap()
172                            .clear_pending_proposal(update_ref)
173                            .await
174                            .unwrap();
175                        assert!(alice_central.pending_proposals(&id).await.is_empty());
176                        assert!(
177                            !alice_central
178                                .pending_proposals(&id)
179                                .await
180                                .into_iter()
181                                .any(|p| matches!(p.proposal(), Proposal::Update(_)))
182                        );
183                    })
184                },
185            )
186            .await
187        }
188
189        #[apply(all_cred_cipher)]
190        #[wasm_bindgen_test]
191        pub async fn should_fail_when_proposal_ref_not_found(case: TestCase) {
192            run_test_with_client_ids(case.clone(), ["alice"], move |[mut alice_central]| {
193                Box::pin(async move {
194                    let id = conversation_id();
195                    alice_central
196                        .context
197                        .new_conversation(&id, case.credential_type, case.cfg.clone())
198                        .await
199                        .unwrap();
200                    assert!(alice_central.pending_proposals(&id).await.is_empty());
201                    let any_ref = MlsProposalRef::from(vec![0; case.ciphersuite().hash_length()]);
202                    let clear = alice_central
203                        .context
204                        .conversation(&id)
205                        .await
206                        .unwrap()
207                        .clear_pending_proposal(any_ref.clone())
208                        .await;
209                    assert!(
210                        matches!(clear.unwrap_err(), Error::PendingProposalNotFound(prop_ref) if prop_ref == any_ref)
211                    )
212                })
213            })
214            .await
215        }
216
217        #[apply(all_cred_cipher)]
218        #[wasm_bindgen_test]
219        pub async fn should_clean_associated_key_material(case: TestCase) {
220            run_test_with_client_ids(case.clone(), ["alice"], move |[mut cc]| {
221                Box::pin(async move {
222                    let id = conversation_id();
223                    cc.context
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.context.count_entities().await;
230
231                    let proposal_ref = cc.context.new_update_proposal(&id).await.unwrap().proposal_ref;
232                    assert_eq!(cc.pending_proposals(&id).await.len(), 1);
233
234                    cc.context
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.context.count_entities().await;
247                    assert_eq!(init, after_clear_proposal);
248                })
249            })
250            .await
251        }
252    }
253
254    mod clear_pending_commit {
255        use super::*;
256
257        #[apply(all_cred_cipher)]
258        #[wasm_bindgen_test]
259        pub async fn should_remove_commit(case: TestCase) {
260            run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
261                Box::pin(async move {
262                    let id = conversation_id();
263                    alice_central
264                        .context
265                        .new_conversation(&id, case.credential_type, case.cfg.clone())
266                        .await
267                        .unwrap();
268                    assert!(alice_central.pending_commit(&id).await.is_none());
269
270                    alice_central.create_unmerged_commit(&id).await;
271                    assert!(alice_central.pending_commit(&id).await.is_some());
272                    alice_central
273                        .context
274                        .conversation(&id)
275                        .await
276                        .unwrap()
277                        .clear_pending_commit()
278                        .await
279                        .unwrap();
280                    assert!(alice_central.pending_commit(&id).await.is_none());
281                })
282            })
283            .await
284        }
285
286        #[apply(all_cred_cipher)]
287        #[wasm_bindgen_test]
288        pub async fn should_fail_when_pending_commit_absent(case: TestCase) {
289            run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
290                Box::pin(async move {
291                    let id = conversation_id();
292                    alice_central
293                        .context
294                        .new_conversation(&id, case.credential_type, case.cfg.clone())
295                        .await
296                        .unwrap();
297                    assert!(alice_central.pending_commit(&id).await.is_none());
298                    let clear = alice_central
299                        .context
300                        .conversation(&id)
301                        .await
302                        .unwrap()
303                        .clear_pending_commit()
304                        .await;
305                    assert!(matches!(clear.unwrap_err(), Error::PendingCommitNotFound))
306                })
307            })
308            .await
309        }
310
311        #[apply(all_cred_cipher)]
312        #[wasm_bindgen_test]
313        pub async fn should_clean_associated_key_material(case: TestCase) {
314            run_test_with_client_ids(case.clone(), ["alice"], move |[cc]| {
315                Box::pin(async move {
316                    let id = conversation_id();
317                    cc.context
318                        .new_conversation(&id, case.credential_type, case.cfg.clone())
319                        .await
320                        .unwrap();
321                    assert!(cc.pending_commit(&id).await.is_none());
322
323                    let init = cc.context.count_entities().await;
324
325                    cc.create_unmerged_commit(&id).await;
326                    assert!(cc.pending_commit(&id).await.is_some());
327
328                    cc.context
329                        .conversation(&id)
330                        .await
331                        .unwrap()
332                        .clear_pending_commit()
333                        .await
334                        .unwrap();
335                    assert!(cc.pending_commit(&id).await.is_none());
336
337                    // This whole flow should be idempotent.
338                    // Here we verify that we are indeed deleting the `EncryptionKeyPair` created
339                    // for the Update commit
340                    let after_clear_commit = cc.context.count_entities().await;
341                    assert_eq!(init, after_clear_commit);
342                })
343            })
344            .await
345        }
346    }
347}