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