core_crypto/mls/conversation/
commit.rs

1//! This table summarizes when a MLS group can create a commit or proposal:
2//!
3//! | can create handshake ? | 0 pend. Commit | 1 pend. Commit |
4//! |------------------------|----------------|----------------|
5//! | 0 pend. Proposal       | ✅              | ❌              |
6//! | 1+ pend. Proposal      | ✅              | ❌              |
7
8use openmls::prelude::{LeafNode, MlsMessageOut};
9
10use mls_crypto_provider::MlsCryptoProvider;
11
12use super::{Error, Result};
13use crate::{
14    LeafError,
15    mls::{MlsConversation, credential::CredentialBundle},
16    prelude::{MlsError, MlsGroupInfoBundle, Session},
17};
18
19impl MlsConversation {
20    /// see [Client::update_keying_material]
21    #[cfg_attr(test, crate::durable)]
22    pub(crate) async fn update_keying_material(
23        &mut self,
24        client: &Session,
25        backend: &MlsCryptoProvider,
26        cb: Option<&CredentialBundle>,
27        leaf_node: Option<LeafNode>,
28    ) -> Result<MlsCommitBundle> {
29        let cb = match cb {
30            None => &self.find_most_recent_credential_bundle(client).await?,
31            Some(cb) => cb,
32        };
33        let (commit, welcome, group_info) = self
34            .group
35            .explicit_self_update(backend, &cb.signature_key, leaf_node)
36            .await
37            .map_err(MlsError::wrap("group self update"))?;
38
39        // We should always have ratchet tree extension turned on hence GroupInfo should always be present
40        let group_info = group_info.ok_or(LeafError::MissingGroupInfo)?;
41        let group_info = MlsGroupInfoBundle::try_new_full_plaintext(group_info)?;
42
43        self.persist_group_when_changed(&backend.keystore(), false).await?;
44
45        Ok(MlsCommitBundle {
46            welcome,
47            commit,
48            group_info,
49        })
50    }
51
52    /// see [Client::commit_pending_proposals]
53    #[cfg_attr(test, crate::durable)]
54    pub(crate) async fn commit_pending_proposals(
55        &mut self,
56        client: &Session,
57        backend: &MlsCryptoProvider,
58    ) -> Result<Option<MlsCommitBundle>> {
59        if self.group.pending_proposals().count() == 0 {
60            return Ok(None);
61        }
62        let signer = &self.find_most_recent_credential_bundle(client).await?.signature_key;
63
64        let (commit, welcome, gi) = self
65            .group
66            .commit_to_pending_proposals(backend, signer)
67            .await
68            .map_err(MlsError::wrap("group commit to pending proposals"))?;
69        let group_info = MlsGroupInfoBundle::try_new_full_plaintext(gi.unwrap())?;
70
71        self.persist_group_when_changed(&backend.keystore(), false).await?;
72
73        Ok(Some(MlsCommitBundle {
74            welcome,
75            commit,
76            group_info,
77        }))
78    }
79}
80
81/// Returned when a commit is created
82#[derive(Debug, Clone)]
83pub struct MlsCommitBundle {
84    /// A welcome message if there are pending Add proposals
85    pub welcome: Option<MlsMessageOut>,
86    /// The commit message
87    pub commit: MlsMessageOut,
88    /// `GroupInfo` if the commit is merged
89    pub group_info: MlsGroupInfoBundle,
90}
91
92impl MlsCommitBundle {
93    /// Serializes both wrapped objects into TLS and return them as a tuple of byte arrays.
94    /// 0 -> welcome
95    /// 1 -> message
96    /// 2 -> public group state
97    #[allow(clippy::type_complexity)]
98    pub fn to_bytes_triple(self) -> Result<(Option<Vec<u8>>, Vec<u8>, MlsGroupInfoBundle)> {
99        use openmls::prelude::TlsSerializeTrait as _;
100        let welcome = self
101            .welcome
102            .as_ref()
103            .map(|w| {
104                w.tls_serialize_detached()
105                    .map_err(Error::tls_serialize("serialize welcome"))
106            })
107            .transpose()?;
108        let commit = self
109            .commit
110            .tls_serialize_detached()
111            .map_err(Error::tls_serialize("serialize commit"))?;
112        Ok((welcome, commit, self.group_info))
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use itertools::Itertools;
119    use openmls::prelude::SignaturePublicKey;
120    use wasm_bindgen_test::*;
121
122    use crate::test_utils::*;
123    use crate::transaction_context::Error as TransactionError;
124
125    use super::{Error, *};
126
127    wasm_bindgen_test_configure!(run_in_browser);
128
129    mod transport {
130        use super::*;
131        use std::sync::Arc;
132
133        #[apply(all_cred_cipher)]
134        #[wasm_bindgen_test]
135        async fn retry_should_work(case: TestCase) {
136            run_test_with_client_ids(
137                case.clone(),
138                ["alice", "bob", "charlie"],
139                move |[alice_central, bob_central, charlie_central]| {
140                    Box::pin(async move {
141                        // Create conversation
142                        let id = conversation_id();
143                        alice_central
144                            .context
145                            .new_conversation(&id, case.credential_type, case.cfg.clone())
146                            .await
147                            .unwrap();
148                        // Add bob
149                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
150
151                        // Bob produces a commit that Alice will receive only after she tried sending a commit
152                        bob_central
153                            .context
154                            .conversation(&id)
155                            .await
156                            .unwrap()
157                            .update_key_material()
158                            .await
159                            .unwrap();
160                        let bob_epoch = bob_central.get_conversation_unchecked(&id).await.group.epoch().as_u64();
161                        assert_eq!(2, bob_epoch);
162                        let alice_epoch = alice_central
163                            .get_conversation_unchecked(&id)
164                            .await
165                            .group
166                            .epoch()
167                            .as_u64();
168                        assert_eq!(1, alice_epoch);
169                        let intermediate_commit = bob_central.mls_transport.latest_commit().await;
170
171                        // Next time a commit is sent, process the intermediate commit and return retry, success the second time
172                        let retry_provider = Arc::new(
173                            CoreCryptoTransportRetrySuccessProvider::default().with_intermediate_commits(
174                                alice_central.clone(),
175                                &[intermediate_commit],
176                                &id,
177                            ),
178                        );
179
180                        alice_central
181                            .context
182                            .set_transport_callbacks(Some(retry_provider.clone()))
183                            .await
184                            .unwrap();
185
186                        // Send two commits and process them on bobs side
187                        alice_central
188                            .context
189                            .conversation(&id)
190                            .await
191                            .unwrap()
192                            .update_key_material()
193                            .await
194                            .unwrap();
195                        let commit = retry_provider.latest_commit().await;
196                        bob_central
197                            .context
198                            .conversation(&id)
199                            .await
200                            .unwrap()
201                            .decrypt_message(&commit.to_bytes().unwrap())
202                            .await
203                            .unwrap();
204
205                        // For this second commit, the retry provider will first return retry and
206                        // then success, but now without an intermediate commit
207                        alice_central
208                            .context
209                            .conversation(&id)
210                            .await
211                            .unwrap()
212                            .add_members(vec![charlie_central.rand_key_package(&case).await])
213                            .await
214                            .unwrap();
215                        let commit = retry_provider.latest_commit().await;
216                        bob_central
217                            .context
218                            .conversation(&id)
219                            .await
220                            .unwrap()
221                            .decrypt_message(&commit.to_bytes().unwrap())
222                            .await
223                            .unwrap();
224
225                        // Retry should have been returned twice
226                        assert_eq!(retry_provider.retry_count().await, 2);
227                        // Success should have been returned twice
228                        assert_eq!(retry_provider.success_count().await, 2);
229
230                        // Group is still in valid state
231                        assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
232                    })
233                },
234            )
235            .await;
236        }
237    }
238
239    mod add_members {
240        use super::*;
241        use std::sync::Arc;
242
243        #[apply(all_cred_cipher)]
244        #[wasm_bindgen_test]
245        async fn can_add_members_to_conversation(case: TestCase) {
246            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
247                Box::pin(async move {
248                    let id = conversation_id();
249
250                    alice_central
251                        .context
252                        .new_conversation(&id, case.credential_type, case.cfg.clone())
253                        .await
254                        .unwrap();
255                    let bob = bob_central.rand_key_package(&case).await;
256                    // First, abort commit transport
257                    alice_central
258                        .context
259                        .set_transport_callbacks(Some(Arc::<CoreCryptoTransportAbortProvider>::default()))
260                        .await
261                        .unwrap();
262                    alice_central
263                        .context
264                        .conversation(&id)
265                        .await
266                        .unwrap()
267                        .add_members(vec![bob.clone()])
268                        .await
269                        .unwrap_err();
270
271                    // commit is not applied
272                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
273
274                    let success_provider = Arc::<CoreCryptoTransportSuccessProvider>::default();
275                    alice_central
276                        .context
277                        .set_transport_callbacks(Some(success_provider.clone()))
278                        .await
279                        .unwrap();
280                    alice_central
281                        .context
282                        .conversation(&id)
283                        .await
284                        .unwrap()
285                        .add_members(vec![bob])
286                        .await
287                        .unwrap();
288
289                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.id, id);
290                    assert_eq!(
291                        alice_central
292                            .get_conversation_unchecked(&id)
293                            .await
294                            .group
295                            .group_id()
296                            .as_slice(),
297                        id
298                    );
299                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
300                    let commit = success_provider.latest_commit_bundle().await;
301                    bob_central
302                        .context
303                        .process_welcome_message(commit.welcome.unwrap().into(), case.custom_cfg())
304                        .await
305                        .unwrap();
306                    assert_eq!(
307                        alice_central.get_conversation_unchecked(&id).await.id(),
308                        bob_central.get_conversation_unchecked(&id).await.id()
309                    );
310                    assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
311                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
312                })
313            })
314            .await
315        }
316
317        #[apply(all_cred_cipher)]
318        #[wasm_bindgen_test]
319        async fn should_return_valid_welcome(case: TestCase) {
320            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
321                Box::pin(async move {
322                    let id = conversation_id();
323                    alice_central
324                        .context
325                        .new_conversation(&id, case.credential_type, case.cfg.clone())
326                        .await
327                        .unwrap();
328
329                    let bob = bob_central.rand_key_package(&case).await;
330                    alice_central
331                        .context
332                        .conversation(&id)
333                        .await
334                        .unwrap()
335                        .add_members(vec![bob])
336                        .await
337                        .unwrap();
338
339                    let welcome = alice_central
340                        .mls_transport
341                        .latest_commit_bundle()
342                        .await
343                        .welcome
344                        .unwrap();
345
346                    bob_central
347                        .context
348                        .process_welcome_message(welcome.into(), case.custom_cfg())
349                        .await
350                        .unwrap();
351                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
352                })
353            })
354            .await
355        }
356
357        #[apply(all_cred_cipher)]
358        #[wasm_bindgen_test]
359        async fn should_return_valid_group_info(case: TestCase) {
360            run_test_with_client_ids(
361                case.clone(),
362                ["alice", "bob", "guest"],
363                move |[alice_central, bob_central, mut guest_central]| {
364                    Box::pin(async move {
365                        let id = conversation_id();
366                        alice_central
367                            .context
368                            .new_conversation(&id, case.credential_type, case.cfg.clone())
369                            .await
370                            .unwrap();
371
372                        let bob = bob_central.rand_key_package(&case).await;
373                        alice_central
374                            .context
375                            .conversation(&id)
376                            .await
377                            .unwrap()
378                            .add_members(vec![bob])
379                            .await
380                            .unwrap();
381                        let commit_bundle = alice_central.mls_transport.latest_commit_bundle().await;
382                        let group_info = commit_bundle.group_info.get_group_info();
383
384                        assert!(
385                            guest_central
386                                .try_join_from_group_info(&case, &id, group_info, vec![&alice_central])
387                                .await
388                                .is_ok()
389                        );
390                    })
391                },
392            )
393            .await
394        }
395    }
396
397    mod remove_members {
398        use super::*;
399
400        #[apply(all_cred_cipher)]
401        #[wasm_bindgen_test]
402        async fn alice_can_remove_bob_from_conversation(case: TestCase) {
403            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
404                Box::pin(async move {
405                    let id = conversation_id();
406
407                    alice_central
408                        .context
409                        .new_conversation(&id, case.credential_type, case.cfg.clone())
410                        .await
411                        .unwrap();
412                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
413
414                    alice_central
415                        .context
416                        .conversation(&id)
417                        .await
418                        .unwrap()
419                        .remove_members(&[bob_central.get_client_id().await])
420                        .await
421                        .unwrap();
422                    let MlsCommitBundle { commit, welcome, .. } =
423                        alice_central.mls_transport.latest_commit_bundle().await;
424                    assert!(welcome.is_none());
425
426                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
427
428                    bob_central
429                        .context
430                        .conversation(&id)
431                        .await
432                        .unwrap()
433                        .decrypt_message(commit.to_bytes().unwrap())
434                        .await
435                        .unwrap();
436
437                    // But has been removed from the conversation
438                    assert!(matches!(
439                    bob_central.context.conversation(&id).await.unwrap_err(),
440                    TransactionError::Leaf(LeafError::ConversationNotFound(ref i))
441                        if i == &id
442                    ));
443                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_err());
444                })
445            })
446            .await;
447        }
448
449        #[apply(all_cred_cipher)]
450        #[wasm_bindgen_test]
451        async fn should_return_valid_welcome(case: TestCase) {
452            run_test_with_client_ids(
453                case.clone(),
454                ["alice", "bob", "guest"],
455                move |[alice_central, bob_central, mut guest_central]| {
456                    Box::pin(async move {
457                        let id = conversation_id();
458
459                        alice_central
460                            .context
461                            .new_conversation(&id, case.credential_type, case.cfg.clone())
462                            .await
463                            .unwrap();
464                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
465
466                        let proposal = alice_central
467                            .context
468                            .new_add_proposal(&id, guest_central.get_one_key_package(&case).await)
469                            .await
470                            .unwrap();
471                        bob_central
472                            .context
473                            .conversation(&id)
474                            .await
475                            .unwrap()
476                            .decrypt_message(proposal.proposal.to_bytes().unwrap())
477                            .await
478                            .unwrap();
479
480                        alice_central
481                            .context
482                            .conversation(&id)
483                            .await
484                            .unwrap()
485                            .remove_members(&[bob_central.get_client_id().await])
486                            .await
487                            .unwrap();
488
489                        let welcome = alice_central.mls_transport.latest_welcome_message().await;
490
491                        assert!(
492                            guest_central
493                                .try_join_from_welcome(&id, welcome.into(), case.custom_cfg(), vec![&alice_central])
494                                .await
495                                .is_ok()
496                        );
497                        // because Bob has been removed from the group
498                        assert!(guest_central.try_talk_to(&id, &bob_central).await.is_err());
499                    })
500                },
501            )
502            .await;
503        }
504
505        #[apply(all_cred_cipher)]
506        #[wasm_bindgen_test]
507        async fn should_return_valid_group_info(case: TestCase) {
508            run_test_with_client_ids(
509                case.clone(),
510                ["alice", "bob", "guest"],
511                move |[alice_central, bob_central, mut guest_central]| {
512                    Box::pin(async move {
513                        let id = conversation_id();
514
515                        alice_central
516                            .context
517                            .new_conversation(&id, case.credential_type, case.cfg.clone())
518                            .await
519                            .unwrap();
520                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
521
522                        alice_central
523                            .context
524                            .conversation(&id)
525                            .await
526                            .unwrap()
527                            .remove_members(&[bob_central.get_client_id().await])
528                            .await
529                            .unwrap();
530
531                        let commit_bundle = alice_central.mls_transport.latest_commit_bundle().await;
532
533                        let group_info = commit_bundle.group_info.get_group_info();
534
535                        assert!(
536                            guest_central
537                                .try_join_from_group_info(&case, &id, group_info, vec![&alice_central])
538                                .await
539                                .is_ok()
540                        );
541                        // because Bob has been removed from the group
542                        assert!(guest_central.try_talk_to(&id, &bob_central).await.is_err());
543                    })
544                },
545            )
546            .await;
547        }
548    }
549
550    mod update_keying_material {
551        use super::*;
552
553        #[apply(all_cred_cipher)]
554        #[wasm_bindgen_test]
555        async fn should_succeed(case: TestCase) {
556            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
557                Box::pin(async move {
558                    let id = conversation_id();
559                    alice_central
560                        .context
561                        .new_conversation(&id, case.credential_type, case.cfg.clone())
562                        .await
563                        .unwrap();
564                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
565
566                    let init_count = alice_central.context.count_entities().await;
567
568                    let bob_keys = bob_central
569                        .get_conversation_unchecked(&id)
570                        .await
571                        .encryption_keys()
572                        .collect::<Vec<Vec<u8>>>();
573                    let alice_keys = alice_central
574                        .get_conversation_unchecked(&id)
575                        .await
576                        .encryption_keys()
577                        .collect::<Vec<Vec<u8>>>();
578                    assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
579
580                    let alice_key = alice_central
581                        .encryption_key_of(&id, alice_central.get_client_id().await)
582                        .await;
583
584                    // proposing the key update for alice
585                    alice_central
586                        .context
587                        .conversation(&id)
588                        .await
589                        .unwrap()
590                        .update_key_material()
591                        .await
592                        .unwrap();
593                    let MlsCommitBundle { commit, welcome, .. } =
594                        alice_central.mls_transport.latest_commit_bundle().await;
595                    assert!(welcome.is_none());
596
597                    assert!(
598                        !alice_central
599                            .get_conversation_unchecked(&id)
600                            .await
601                            .encryption_keys()
602                            .contains(&alice_key)
603                    );
604
605                    let alice_new_keys = alice_central
606                        .get_conversation_unchecked(&id)
607                        .await
608                        .encryption_keys()
609                        .collect::<Vec<_>>();
610                    assert!(!alice_new_keys.contains(&alice_key));
611
612                    // receiving the commit on bob's side (updating key from alice)
613                    bob_central
614                        .context
615                        .conversation(&id)
616                        .await
617                        .unwrap()
618                        .decrypt_message(&commit.to_bytes().unwrap())
619                        .await
620                        .unwrap();
621
622                    let bob_new_keys = bob_central
623                        .get_conversation_unchecked(&id)
624                        .await
625                        .encryption_keys()
626                        .collect::<Vec<_>>();
627                    assert!(alice_new_keys.iter().all(|a_key| bob_new_keys.contains(a_key)));
628
629                    // ensuring both can encrypt messages
630                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
631
632                    // make sure inline update commit + merge does not leak anything
633                    // that's obvious since no new encryption keypair is created in this case
634                    let final_count = alice_central.context.count_entities().await;
635                    assert_eq!(init_count, final_count);
636                })
637            })
638            .await;
639        }
640
641        #[apply(all_cred_cipher)]
642        #[wasm_bindgen_test]
643        async fn should_create_welcome_for_pending_add_proposals(case: TestCase) {
644            run_test_with_client_ids(
645                case.clone(),
646                ["alice", "bob", "charlie"],
647                move |[alice_central, bob_central, charlie_central]| {
648                    Box::pin(async move {
649                        let id = conversation_id();
650                        alice_central
651                            .context
652                            .new_conversation(&id, case.credential_type, case.cfg.clone())
653                            .await
654                            .unwrap();
655                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
656
657                        let bob_keys = bob_central
658                            .get_conversation_unchecked(&id)
659                            .await
660                            .signature_keys()
661                            .collect::<Vec<SignaturePublicKey>>();
662                        let alice_keys = alice_central
663                            .get_conversation_unchecked(&id)
664                            .await
665                            .signature_keys()
666                            .collect::<Vec<SignaturePublicKey>>();
667
668                        // checking that the members on both sides are the same
669                        assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
670
671                        let alice_key = alice_central
672                            .encryption_key_of(&id, alice_central.get_client_id().await)
673                            .await;
674
675                        // proposing adding charlie
676                        let charlie_kp = charlie_central.get_one_key_package(&case).await;
677                        let add_charlie_proposal =
678                            alice_central.context.new_add_proposal(&id, charlie_kp).await.unwrap();
679
680                        // receiving the proposal on Bob's side
681                        bob_central
682                            .context
683                            .conversation(&id)
684                            .await
685                            .unwrap()
686                            .decrypt_message(add_charlie_proposal.proposal.to_bytes().unwrap())
687                            .await
688                            .unwrap();
689
690                        assert!(
691                            alice_central
692                                .get_conversation_unchecked(&id)
693                                .await
694                                .encryption_keys()
695                                .contains(&alice_key)
696                        );
697
698                        // performing an update on Alice's key. this should generate a welcome for Charlie
699                        alice_central
700                            .context
701                            .conversation(&id)
702                            .await
703                            .unwrap()
704                            .update_key_material()
705                            .await
706                            .unwrap();
707                        let MlsCommitBundle { commit, welcome, .. } =
708                            alice_central.mls_transport.latest_commit_bundle().await;
709                        assert!(welcome.is_some());
710                        assert!(
711                            !alice_central
712                                .get_conversation_unchecked(&id)
713                                .await
714                                .encryption_keys()
715                                .contains(&alice_key)
716                        );
717
718                        // create the group on charlie's side
719                        charlie_central
720                            .context
721                            .process_welcome_message(welcome.unwrap().into(), case.custom_cfg())
722                            .await
723                            .unwrap();
724
725                        assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
726                        assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
727                        // bob still didn't receive the message with the updated key and charlie's addition
728                        assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
729
730                        let alice_new_keys = alice_central
731                            .get_conversation_unchecked(&id)
732                            .await
733                            .encryption_keys()
734                            .collect::<Vec<Vec<u8>>>();
735                        assert!(!alice_new_keys.contains(&alice_key));
736
737                        // receiving the key update and the charlie's addition to the group
738                        bob_central
739                            .context
740                            .conversation(&id)
741                            .await
742                            .unwrap()
743                            .decrypt_message(&commit.to_bytes().unwrap())
744                            .await
745                            .unwrap();
746                        assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
747
748                        let bob_new_keys = bob_central
749                            .get_conversation_unchecked(&id)
750                            .await
751                            .encryption_keys()
752                            .collect::<Vec<Vec<u8>>>();
753                        assert!(alice_new_keys.iter().all(|a_key| bob_new_keys.contains(a_key)));
754
755                        // ensure all parties can encrypt messages
756                        assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
757                        assert!(bob_central.try_talk_to(&id, &charlie_central).await.is_ok());
758                        assert!(charlie_central.try_talk_to(&id, &alice_central).await.is_ok());
759                    })
760                },
761            )
762            .await;
763        }
764
765        #[apply(all_cred_cipher)]
766        #[wasm_bindgen_test]
767        async fn should_return_valid_welcome(case: TestCase) {
768            run_test_with_client_ids(
769                case.clone(),
770                ["alice", "bob", "guest"],
771                move |[alice_central, bob_central, mut guest_central]| {
772                    Box::pin(async move {
773                        let id = conversation_id();
774                        alice_central
775                            .context
776                            .new_conversation(&id, case.credential_type, case.cfg.clone())
777                            .await
778                            .unwrap();
779                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
780
781                        let proposal = alice_central
782                            .context
783                            .new_add_proposal(&id, guest_central.get_one_key_package(&case).await)
784                            .await
785                            .unwrap()
786                            .proposal;
787                        bob_central
788                            .context
789                            .conversation(&id)
790                            .await
791                            .unwrap()
792                            .decrypt_message(proposal.to_bytes().unwrap())
793                            .await
794                            .unwrap();
795
796                        alice_central
797                            .context
798                            .conversation(&id)
799                            .await
800                            .unwrap()
801                            .update_key_material()
802                            .await
803                            .unwrap();
804                        let MlsCommitBundle { commit, welcome, .. } =
805                            alice_central.mls_transport.latest_commit_bundle().await;
806
807                        bob_central
808                            .context
809                            .conversation(&id)
810                            .await
811                            .unwrap()
812                            .decrypt_message(commit.to_bytes().unwrap())
813                            .await
814                            .unwrap();
815
816                        assert!(
817                            guest_central
818                                .try_join_from_welcome(
819                                    &id,
820                                    welcome.unwrap().into(),
821                                    case.custom_cfg(),
822                                    vec![&alice_central, &bob_central]
823                                )
824                                .await
825                                .is_ok()
826                        );
827                    })
828                },
829            )
830            .await;
831        }
832
833        #[apply(all_cred_cipher)]
834        #[wasm_bindgen_test]
835        async fn should_return_valid_group_info(case: TestCase) {
836            run_test_with_client_ids(
837                case.clone(),
838                ["alice", "bob", "guest"],
839                move |[alice_central, bob_central, mut guest_central]| {
840                    Box::pin(async move {
841                        let id = conversation_id();
842                        alice_central
843                            .context
844                            .new_conversation(&id, case.credential_type, case.cfg.clone())
845                            .await
846                            .unwrap();
847                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
848
849                        alice_central
850                            .context
851                            .conversation(&id)
852                            .await
853                            .unwrap()
854                            .update_key_material()
855                            .await
856                            .unwrap();
857                        let group_info = alice_central.mls_transport.latest_group_info().await;
858                        let group_info = group_info.get_group_info();
859
860                        assert!(
861                            guest_central
862                                .try_join_from_group_info(&case, &id, group_info, vec![&alice_central])
863                                .await
864                                .is_ok()
865                        );
866                    })
867                },
868            )
869            .await;
870        }
871    }
872
873    mod commit_pending_proposals {
874        use super::*;
875
876        #[apply(all_cred_cipher)]
877        #[wasm_bindgen_test]
878        async fn should_create_a_commit_out_of_self_pending_proposals(case: TestCase) {
879            run_test_with_client_ids(
880                case.clone(),
881                ["alice", "bob"],
882                move |[mut alice_central, bob_central]| {
883                    Box::pin(async move {
884                        let id = conversation_id();
885                        alice_central
886                            .context
887                            .new_conversation(&id, case.credential_type, case.cfg.clone())
888                            .await
889                            .unwrap();
890                        alice_central
891                            .context
892                            .new_add_proposal(&id, bob_central.get_one_key_package(&case).await)
893                            .await
894                            .unwrap();
895                        assert!(!alice_central.pending_proposals(&id).await.is_empty());
896                        assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
897                        alice_central
898                            .context
899                            .conversation(&id)
900                            .await
901                            .unwrap()
902                            .commit_pending_proposals()
903                            .await
904                            .unwrap();
905                        assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
906
907                        let welcome = alice_central.mls_transport.latest_commit_bundle().await.welcome;
908                        bob_central
909                            .context
910                            .process_welcome_message(welcome.unwrap().into(), case.custom_cfg())
911                            .await
912                            .unwrap();
913                        assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
914                    })
915                },
916            )
917            .await;
918        }
919
920        #[apply(all_cred_cipher)]
921        #[wasm_bindgen_test]
922        async fn should_create_a_commit_out_of_pending_proposals_by_ref(case: TestCase) {
923            run_test_with_client_ids(
924                case.clone(),
925                ["alice", "bob", "charlie"],
926                move |[alice_central, mut bob_central, charlie_central]| {
927                    Box::pin(async move {
928                        let id = conversation_id();
929                        alice_central
930                            .context
931                            .new_conversation(&id, case.credential_type, case.cfg.clone())
932                            .await
933                            .unwrap();
934                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
935                        let proposal = bob_central
936                            .context
937                            .new_add_proposal(&id, charlie_central.get_one_key_package(&case).await)
938                            .await
939                            .unwrap();
940                        assert!(!bob_central.pending_proposals(&id).await.is_empty());
941                        assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
942                        alice_central
943                            .context
944                            .conversation(&id)
945                            .await
946                            .unwrap()
947                            .decrypt_message(proposal.proposal.to_bytes().unwrap())
948                            .await
949                            .unwrap();
950
951                        alice_central
952                            .context
953                            .conversation(&id)
954                            .await
955                            .unwrap()
956                            .commit_pending_proposals()
957                            .await
958                            .unwrap();
959                        let commit = alice_central.mls_transport.latest_commit_bundle().await.commit;
960                        assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
961
962                        bob_central
963                            .context
964                            .conversation(&id)
965                            .await
966                            .unwrap()
967                            .decrypt_message(commit.to_bytes().unwrap())
968                            .await
969                            .unwrap();
970                        assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
971                        assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
972                    })
973                },
974            )
975            .await;
976        }
977
978        #[apply(all_cred_cipher)]
979        #[wasm_bindgen_test]
980        async fn should_return_valid_welcome(case: TestCase) {
981            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
982                Box::pin(async move {
983                    let id = conversation_id();
984                    alice_central
985                        .context
986                        .new_conversation(&id, case.credential_type, case.cfg.clone())
987                        .await
988                        .unwrap();
989                    alice_central
990                        .context
991                        .new_add_proposal(&id, bob_central.get_one_key_package(&case).await)
992                        .await
993                        .unwrap();
994                    alice_central
995                        .context
996                        .conversation(&id)
997                        .await
998                        .unwrap()
999                        .commit_pending_proposals()
1000                        .await
1001                        .unwrap();
1002
1003                    let welcome = alice_central.mls_transport.latest_commit_bundle().await.welcome;
1004
1005                    bob_central
1006                        .context
1007                        .process_welcome_message(welcome.unwrap().into(), case.custom_cfg())
1008                        .await
1009                        .unwrap();
1010                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
1011                })
1012            })
1013            .await;
1014        }
1015
1016        #[apply(all_cred_cipher)]
1017        #[wasm_bindgen_test]
1018        async fn should_return_valid_group_info(case: TestCase) {
1019            run_test_with_client_ids(
1020                case.clone(),
1021                ["alice", "bob", "guest"],
1022                move |[alice_central, bob_central, mut guest_central]| {
1023                    Box::pin(async move {
1024                        let id = conversation_id();
1025                        alice_central
1026                            .context
1027                            .new_conversation(&id, case.credential_type, case.cfg.clone())
1028                            .await
1029                            .unwrap();
1030                        alice_central
1031                            .context
1032                            .new_add_proposal(&id, bob_central.get_one_key_package(&case).await)
1033                            .await
1034                            .unwrap();
1035                        alice_central
1036                            .context
1037                            .conversation(&id)
1038                            .await
1039                            .unwrap()
1040                            .commit_pending_proposals()
1041                            .await
1042                            .unwrap();
1043                        let commit_bundle = alice_central.mls_transport.latest_commit_bundle().await;
1044                        let group_info = commit_bundle.group_info.get_group_info();
1045
1046                        assert!(
1047                            guest_central
1048                                .try_join_from_group_info(&case, &id, group_info, vec![&alice_central])
1049                                .await
1050                                .is_ok()
1051                        );
1052                    })
1053                },
1054            )
1055            .await;
1056        }
1057    }
1058
1059    mod delivery_semantics {
1060        use super::*;
1061
1062        #[apply(all_cred_cipher)]
1063        #[wasm_bindgen_test]
1064        async fn should_prevent_out_of_order_commits(case: TestCase) {
1065            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
1066                Box::pin(async move {
1067                    let id = conversation_id();
1068                    alice_central
1069                        .context
1070                        .new_conversation(&id, case.credential_type, case.cfg.clone())
1071                        .await
1072                        .unwrap();
1073                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
1074
1075                    alice_central
1076                        .context
1077                        .conversation(&id)
1078                        .await
1079                        .unwrap()
1080                        .update_key_material()
1081                        .await
1082                        .unwrap();
1083                    let commit1 = alice_central.mls_transport.latest_commit().await;
1084                    let commit1 = commit1.to_bytes().unwrap();
1085                    alice_central
1086                        .context
1087                        .conversation(&id)
1088                        .await
1089                        .unwrap()
1090                        .update_key_material()
1091                        .await
1092                        .unwrap();
1093                    let commit2 = alice_central.mls_transport.latest_commit().await;
1094                    let commit2 = commit2.to_bytes().unwrap();
1095
1096                    // fails when a commit is skipped
1097                    let out_of_order = bob_central
1098                        .context
1099                        .conversation(&id)
1100                        .await
1101                        .unwrap()
1102                        .decrypt_message(&commit2)
1103                        .await;
1104                    assert!(matches!(out_of_order.unwrap_err(), Error::BufferedFutureMessage { .. }));
1105
1106                    // works in the right order though
1107                    // NB: here 'commit2' has been buffered so it is also applied when we decrypt commit1
1108                    bob_central
1109                        .context
1110                        .conversation(&id)
1111                        .await
1112                        .unwrap()
1113                        .decrypt_message(&commit1)
1114                        .await
1115                        .unwrap();
1116
1117                    // and then fails again when trying to decrypt a commit with an epoch in the past
1118                    let past_commit = bob_central
1119                        .context
1120                        .conversation(&id)
1121                        .await
1122                        .unwrap()
1123                        .decrypt_message(&commit1)
1124                        .await;
1125                    assert!(matches!(past_commit.unwrap_err(), Error::StaleCommit));
1126                })
1127            })
1128            .await;
1129        }
1130
1131        #[apply(all_cred_cipher)]
1132        #[wasm_bindgen_test]
1133        async fn should_prevent_replayed_encrypted_handshake_messages(case: TestCase) {
1134            if !case.is_pure_ciphertext() {
1135                return;
1136            }
1137            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
1138                Box::pin(async move {
1139                    let id = conversation_id();
1140                    alice_central
1141                        .context
1142                        .new_conversation(&id, case.credential_type, case.cfg.clone())
1143                        .await
1144                        .unwrap();
1145                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
1146
1147                    let proposal1 = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
1148                    let proposal2 = proposal1.clone();
1149                    alice_central
1150                        .get_conversation_unchecked(&id)
1151                        .await
1152                        .group
1153                        .clear_pending_proposals();
1154
1155                    alice_central
1156                        .context
1157                        .conversation(&id)
1158                        .await
1159                        .unwrap()
1160                        .update_key_material()
1161                        .await
1162                        .unwrap();
1163                    let commit1 = alice_central.mls_transport.latest_commit().await;
1164                    let commit2 = commit1.clone();
1165
1166                    // replayed encrypted proposal should fail
1167                    bob_central
1168                        .context
1169                        .conversation(&id)
1170                        .await
1171                        .unwrap()
1172                        .decrypt_message(proposal1.to_bytes().unwrap())
1173                        .await
1174                        .unwrap();
1175                    assert!(matches!(
1176                        bob_central
1177                            .context
1178                            .conversation(&id)
1179                            .await
1180                            .unwrap()
1181                            .decrypt_message(proposal2.to_bytes().unwrap())
1182                            .await
1183                            .unwrap_err(),
1184                        Error::DuplicateMessage
1185                    ));
1186                    bob_central
1187                        .get_conversation_unchecked(&id)
1188                        .await
1189                        .group
1190                        .clear_pending_proposals();
1191
1192                    // replayed encrypted commit should fail
1193                    bob_central
1194                        .context
1195                        .conversation(&id)
1196                        .await
1197                        .unwrap()
1198                        .decrypt_message(commit1.to_bytes().unwrap())
1199                        .await
1200                        .unwrap();
1201                    assert!(matches!(
1202                        bob_central
1203                            .context
1204                            .conversation(&id)
1205                            .await
1206                            .unwrap()
1207                            .decrypt_message(commit2.to_bytes().unwrap())
1208                            .await
1209                            .unwrap_err(),
1210                        Error::StaleCommit
1211                    ));
1212                })
1213            })
1214            .await;
1215        }
1216    }
1217}