core_crypto/mls/
external_commit.rs

1// Wire
2// Copyright (C) 2022 Wire Swiss GmbH
3
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with this program. If not, see http://www.gnu.org/licenses/.
16
17use mls_crypto_provider::MlsCryptoProvider;
18use openmls::prelude::{
19    group_info::VerifiableGroupInfo, CredentialType, MlsGroup, MlsMessageOut, Proposal, Sender, StagedCommit,
20};
21use openmls_traits::OpenMlsCryptoProvider;
22use tls_codec::Serialize;
23
24use core_crypto_keystore::{
25    connection::FetchFromDatabase,
26    entities::{MlsPendingMessage, PersistedMlsPendingGroup},
27    CryptoKeystoreMls,
28};
29
30use crate::{
31    e2e_identity::{conversation_state::compute_state, init_certificates::NewCrlDistributionPoint},
32    group_store::GroupStoreValue,
33    mls::credential::crl::{extract_crl_uris_from_group, get_new_crl_distribution_points},
34    prelude::{
35        decrypt::MlsBufferedConversationDecryptMessage, id::ClientId, ConversationId, CoreCryptoCallbacks, CryptoError,
36        CryptoResult, E2eiConversationState, MlsCiphersuite, MlsConversation, MlsConversationConfiguration,
37        MlsCredentialType, MlsCustomConfiguration, MlsError, MlsGroupInfoBundle,
38    },
39};
40
41use crate::context::CentralContext;
42
43/// Returned when a commit is created
44#[derive(Debug)]
45pub struct MlsConversationInitBundle {
46    /// Identifier of the conversation joined by external commit
47    pub conversation_id: ConversationId,
48    /// The external commit message
49    pub commit: MlsMessageOut,
50    /// `GroupInfo` which becomes valid when the external commit is accepted by the Delivery Service
51    pub group_info: MlsGroupInfoBundle,
52    /// New CRL distribution points that appeared by the introduction of a new credential
53    pub crl_new_distribution_points: NewCrlDistributionPoint,
54}
55
56impl MlsConversationInitBundle {
57    /// Serializes both wrapped objects into TLS and return them as a tuple of byte arrays.
58    /// 0 -> external commit
59    /// 1 -> public group state
60    #[allow(clippy::type_complexity)]
61    pub fn to_bytes(self) -> CryptoResult<(Vec<u8>, MlsGroupInfoBundle, NewCrlDistributionPoint)> {
62        let commit = self.commit.tls_serialize_detached().map_err(MlsError::from)?;
63        Ok((commit, self.group_info, self.crl_new_distribution_points))
64    }
65}
66
67impl CentralContext {
68    /// Issues an external commit and stores the group in a temporary table. This method is
69    /// intended for example when a new client wants to join the user's existing groups.
70    /// On success this function will return the group id and a message to be fanned out to other
71    /// clients.
72    ///
73    /// If the Delivery Service accepts the external commit, you have to [CentralContext::merge_pending_group_from_external_commit]
74    /// in order to get back a functional MLS group. On the opposite, if it rejects it, you can either
75    /// retry by just calling again [CentralContext::join_by_external_commit], no need to [CentralContext::clear_pending_group_from_external_commit].
76    /// If you want to abort the operation (too many retries or the user decided to abort), you can use
77    /// [CentralContext::clear_pending_group_from_external_commit] in order not to bloat the user's storage but nothing
78    /// bad can happen if you forget to except some storage space wasted.
79    ///
80    /// # Arguments
81    /// * `group_info` - a GroupInfo wrapped in a MLS message. it can be obtained by deserializing a TLS serialized `GroupInfo` object
82    /// * `custom_cfg` - configuration of the MLS conversation fetched from the Delivery Service
83    /// * `credential_type` - kind of [openmls::prelude::Credential] to use for joining this group.
84    ///   If [MlsCredentialType::Basic] is chosen and no Credential has been created yet for it,
85    ///   a new one will be generated. When [MlsCredentialType::X509] is chosen, it fails when no
86    ///   [openmls::prelude::Credential] has been created for the given Ciphersuite.
87    ///
88    /// # Return type
89    /// It will return a tuple with the group/conversation id and the message containing the
90    /// commit that was generated by this call
91    ///
92    /// # Errors
93    /// Errors resulting from OpenMls, the KeyStore calls and serialization
94    pub async fn join_by_external_commit(
95        &self,
96        group_info: VerifiableGroupInfo,
97        custom_cfg: MlsCustomConfiguration,
98        credential_type: MlsCredentialType,
99    ) -> CryptoResult<MlsConversationInitBundle> {
100        let client = &self.mls_client().await?;
101
102        let cs: MlsCiphersuite = group_info.ciphersuite().into();
103        let mls_provider = self.mls_provider().await?;
104        let cb = client
105            .get_most_recent_or_create_credential_bundle(&mls_provider, cs.signature_algorithm(), credential_type)
106            .await?;
107
108        let serialized_cfg = serde_json::to_vec(&custom_cfg).map_err(MlsError::MlsKeystoreSerializationError)?;
109
110        let configuration = MlsConversationConfiguration {
111            ciphersuite: cs,
112            custom: custom_cfg,
113            ..Default::default()
114        };
115
116        let (group, commit, group_info) = MlsGroup::join_by_external_commit(
117            &mls_provider,
118            &cb.signature_key,
119            None,
120            group_info,
121            &configuration.as_openmls_default_configuration()?,
122            &[],
123            cb.to_mls_credential_with_key(),
124        )
125        .await
126        .map_err(MlsError::from)?;
127
128        // We should always have ratchet tree extension turned on hence GroupInfo should always be present
129        let group_info = group_info.ok_or(CryptoError::ImplementationError)?;
130        let group_info = MlsGroupInfoBundle::try_new_full_plaintext(group_info)?;
131
132        let crl_new_distribution_points =
133            get_new_crl_distribution_points(&mls_provider, extract_crl_uris_from_group(&group)?).await?;
134
135        mls_provider
136            .key_store()
137            .mls_pending_groups_save(
138                group.group_id().as_slice(),
139                &core_crypto_keystore::ser(&group)?,
140                &serialized_cfg,
141                None,
142            )
143            .await?;
144
145        Ok(MlsConversationInitBundle {
146            conversation_id: group.group_id().to_vec(),
147            commit,
148            group_info,
149            crl_new_distribution_points,
150        })
151    }
152
153    /// This merges the commit generated by [CentralContext::join_by_external_commit], persists the group permanently and
154    /// deletes the temporary one. After merging, the group should be fully functional.
155    ///
156    /// # Arguments
157    /// * `id` - the conversation id
158    ///
159    /// # Errors
160    /// Errors resulting from OpenMls, the KeyStore calls and deserialization
161    #[cfg_attr(test, crate::dispotent)]
162    pub async fn merge_pending_group_from_external_commit(
163        &self,
164        id: &ConversationId,
165    ) -> CryptoResult<Option<Vec<MlsBufferedConversationDecryptMessage>>> {
166        // Retrieve the pending MLS group from the keystore
167        let mls_provider = self.mls_provider().await?;
168        let (group, cfg) = mls_provider.key_store().mls_pending_groups_load(id).await?;
169
170        let mut mls_group = core_crypto_keystore::deser::<MlsGroup>(&group)?;
171
172        // Merge it aka bring the MLS group to life and make it usable
173        mls_group
174            .merge_pending_commit(&mls_provider)
175            .await
176            .map_err(MlsError::from)?;
177
178        // Restore the custom configuration and build a conversation from it
179        let custom_cfg = serde_json::from_slice(&cfg).map_err(MlsError::MlsKeystoreSerializationError)?;
180        let configuration = MlsConversationConfiguration {
181            ciphersuite: mls_group.ciphersuite().into(),
182            custom: custom_cfg,
183            ..Default::default()
184        };
185
186        let is_rejoin = mls_provider.key_store().mls_group_exists(id.as_slice()).await;
187
188        // Persist the now usable MLS group in the keystore
189        // TODO: find a way to make the insertion of the MlsGroup and deletion of the pending group transactional. Tracking issue: WPB-9595
190        let mut conversation = MlsConversation::from_mls_group(mls_group, configuration, &mls_provider).await?;
191
192        let pending_messages = self.restore_pending_messages(&mut conversation, is_rejoin).await?;
193
194        self.mls_groups().await?.insert(id.clone(), conversation);
195
196        // cleanup the pending group we no longer need
197        mls_provider.key_store().mls_pending_groups_delete(id).await?;
198
199        if pending_messages.is_some() {
200            mls_provider.key_store().remove::<MlsPendingMessage, _>(id).await?;
201        }
202
203        Ok(pending_messages)
204    }
205
206    /// In case the external commit generated by [CentralContext::join_by_external_commit] is rejected by the Delivery Service
207    /// and we want to abort this external commit once for all, we can wipe out the pending group from
208    /// the keystore in order not to waste space
209    ///
210    /// # Arguments
211    /// * `id` - the conversation id
212    ///
213    /// # Errors
214    /// Errors resulting from the KeyStore calls
215    #[cfg_attr(test, crate::dispotent)]
216    pub async fn clear_pending_group_from_external_commit(&self, id: &ConversationId) -> CryptoResult<()> {
217        Ok(self.keystore().await?.mls_pending_groups_delete(id).await?)
218    }
219
220    pub(crate) async fn pending_group_exists(&self, id: &ConversationId) -> CryptoResult<bool> {
221        Ok(self
222            .keystore()
223            .await?
224            .find::<PersistedMlsPendingGroup>(id.as_slice())
225            .await
226            .ok()
227            .flatten()
228            .is_some())
229    }
230}
231
232impl MlsConversation {
233    pub(crate) async fn validate_external_commit(
234        &self,
235        commit: &StagedCommit,
236        sender: ClientId,
237        parent_conversation: Option<&GroupStoreValue<MlsConversation>>,
238        backend: &MlsCryptoProvider,
239        callbacks: Option<&dyn CoreCryptoCallbacks>,
240    ) -> CryptoResult<()> {
241        // i.e. has this commit been created by [MlsCentral::join_by_external_commit] ?
242        let is_external_init = commit.queued_proposals().any(|p| {
243            matches!(p.sender(), Sender::NewMemberCommit) && matches!(p.proposal(), Proposal::ExternalInit(_))
244        });
245
246        if is_external_init {
247            let callbacks = callbacks.ok_or(CryptoError::CallbacksNotSet)?;
248            // first let's verify the sender belongs to an user already in the MLS group
249            let existing_clients = self.members_in_next_epoch();
250            let parent_clients = if let Some(parent_conv) = parent_conversation {
251                Some(
252                    parent_conv
253                        .read()
254                        .await
255                        .group
256                        .members()
257                        .map(|kp| kp.credential.identity().to_vec().into())
258                        .collect(),
259                )
260            } else {
261                None
262            };
263            if !callbacks
264                .client_is_existing_group_user(
265                    self.id.clone(),
266                    sender.clone(),
267                    existing_clients.clone(),
268                    parent_clients,
269                )
270                .await
271            {
272                return Err(CryptoError::UnauthorizedExternalCommit);
273            }
274            // then verify that the user this client belongs to has the right role (is allowed)
275            // to perform such operation
276            if !callbacks
277                .user_authorize(self.id.clone(), sender, existing_clients)
278                .await
279            {
280                return Err(CryptoError::UnauthorizedExternalCommit);
281            }
282        }
283
284        if backend.authentication_service().is_env_setup().await {
285            let credentials: Vec<_> = commit
286                .add_proposals()
287                .filter_map(|add_proposal| {
288                    let credential = add_proposal.add_proposal().key_package().leaf_node().credential();
289
290                    matches!(credential.credential_type(), CredentialType::X509).then(|| credential.clone())
291                })
292                .collect();
293            let state = compute_state(
294                self.ciphersuite(),
295                credentials.iter(),
296                MlsCredentialType::X509,
297                backend.authentication_service().borrow().await.as_ref(),
298            )
299            .await;
300            if state != E2eiConversationState::Verified {
301                // FIXME: Uncomment when PKI env can be seeded - the computation is still done to assess performance and impact of the validations. Tracking issue: WPB-9665
302                // return Err(CryptoError::InvalidCertificateChain);
303            }
304        }
305
306        Ok(())
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use openmls::prelude::*;
313    use std::sync::Arc;
314    use wasm_bindgen_test::*;
315
316    use core_crypto_keystore::{CryptoKeystoreError, CryptoKeystoreMls, MissingKeyErrorKind};
317
318    use crate::prelude::MlsConversationConfiguration;
319    use crate::{prelude::MlsConversationInitBundle, test_utils::*, CryptoError};
320
321    wasm_bindgen_test_configure!(run_in_browser);
322
323    #[apply(all_cred_cipher)]
324    #[wasm_bindgen_test]
325    async fn join_by_external_commit_should_succeed(case: TestCase) {
326        run_test_with_client_ids(
327            case.clone(),
328            ["alice", "bob"],
329            move |[alice_central, mut bob_central]| {
330                Box::pin(async move {
331                    let id = conversation_id();
332                    alice_central
333                        .context
334                        .new_conversation(&id, case.credential_type, case.cfg.clone())
335                        .await
336                        .unwrap();
337
338                    // export Alice group info
339                    let group_info = alice_central.get_group_info(&id).await;
340
341                    // Bob tries to join Alice's group
342                    let MlsConversationInitBundle {
343                        conversation_id: group_id,
344                        commit: external_commit,
345                        ..
346                    } = bob_central
347                        .context
348                        .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
349                        .await
350                        .unwrap();
351                    assert_eq!(group_id.as_slice(), &id);
352
353                    // Alice acks the request and adds the new member
354                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
355                    let decrypted = alice_central
356                        .context
357                        .decrypt_message(&id, &external_commit.to_bytes().unwrap())
358                        .await
359                        .unwrap();
360                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
361
362                    // verify Bob's (sender) identity
363                    bob_central.verify_sender_identity(&case, &decrypted).await;
364
365                    // Let's say backend accepted our external commit.
366                    // So Bob can merge the commit and update the local state
367                    assert!(bob_central.context.get_conversation(&id).await.is_err());
368                    bob_central
369                        .context
370                        .merge_pending_group_from_external_commit(&id)
371                        .await
372                        .unwrap();
373                    assert!(bob_central.context.get_conversation(&id).await.is_ok());
374                    assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
375                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
376
377                    // Pending group removed from keystore
378                    let error = alice_central
379                        .context
380                        .keystore()
381                        .await
382                        .unwrap()
383                        .mls_pending_groups_load(&id)
384                        .await;
385                    assert!(matches!(
386                        error.unwrap_err(),
387                        CryptoKeystoreError::MissingKeyInStore(MissingKeyErrorKind::MlsPendingGroup)
388                    ));
389
390                    // Ensure it's durable i.e. MLS group has been persisted
391                    bob_central.context.drop_and_restore(&group_id).await;
392                    assert!(bob_central.try_talk_to(&id, &alice_central).await.is_ok());
393                })
394            },
395        )
396        .await
397    }
398
399    #[apply(all_cred_cipher)]
400    #[wasm_bindgen_test]
401    async fn join_by_external_commit_should_be_retriable(case: TestCase) {
402        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
403            Box::pin(async move {
404                let id = conversation_id();
405                alice_central
406                    .context
407                    .new_conversation(&id, case.credential_type, case.cfg.clone())
408                    .await
409                    .unwrap();
410
411                // export Alice group info
412                let group_info = alice_central.get_group_info(&id).await;
413
414                // Bob tries to join Alice's group
415                bob_central
416                    .context
417                    .join_by_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
418                    .await
419                    .unwrap();
420                // BUT for some reason the Delivery Service will reject this external commit
421                // e.g. another commit arrived meanwhile and the [GroupInfo] is no longer valid
422
423                // Retrying
424                let MlsConversationInitBundle {
425                    conversation_id,
426                    commit: external_commit,
427                    ..
428                } = bob_central
429                    .context
430                    .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
431                    .await
432                    .unwrap();
433                assert_eq!(conversation_id.as_slice(), &id);
434
435                // Alice decrypts the external commit and adds Bob
436                assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
437                alice_central
438                    .context
439                    .decrypt_message(&id, &external_commit.to_bytes().unwrap())
440                    .await
441                    .unwrap();
442                assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
443
444                // And Bob can merge its external commit
445                bob_central
446                    .context
447                    .merge_pending_group_from_external_commit(&id)
448                    .await
449                    .unwrap();
450                assert!(bob_central.context.get_conversation(&id).await.is_ok());
451                assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
452                assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
453            })
454        })
455        .await
456    }
457
458    #[apply(all_cred_cipher)]
459    #[wasm_bindgen_test]
460    async fn should_fail_when_bad_epoch(case: TestCase) {
461        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
462            Box::pin(async move {
463                let id = conversation_id();
464                alice_central
465                    .context
466                    .new_conversation(&id, case.credential_type, case.cfg.clone())
467                    .await
468                    .unwrap();
469
470                let group_info = alice_central.get_group_info(&id).await;
471                // try to make an external join into Alice's group
472                let MlsConversationInitBundle {
473                    commit: external_commit,
474                    ..
475                } = bob_central
476                    .context
477                    .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
478                    .await
479                    .unwrap();
480
481                // Alice creates a new commit before receiving the external join
482                alice_central.context.update_keying_material(&id).await.unwrap();
483                alice_central.context.commit_accepted(&id).await.unwrap();
484
485                // receiving the external join with outdated epoch should fail because of
486                // the wrong epoch
487                let result = alice_central
488                    .context
489                    .decrypt_message(&id, &external_commit.to_bytes().unwrap())
490                    .await;
491                assert!(matches!(result.unwrap_err(), crate::CryptoError::StaleCommit));
492            })
493        })
494        .await
495    }
496
497    #[apply(all_cred_cipher)]
498    #[wasm_bindgen_test]
499    async fn existing_clients_can_join(case: TestCase) {
500        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
501            Box::pin(async move {
502                let id = conversation_id();
503                alice_central
504                    .context
505                    .new_conversation(&id, case.credential_type, case.cfg.clone())
506                    .await
507                    .unwrap();
508                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
509                let group_info = alice_central.get_group_info(&id).await;
510                // Alice can rejoin by external commit
511                alice_central
512                    .context
513                    .join_by_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
514                    .await
515                    .unwrap();
516                alice_central
517                    .context
518                    .merge_pending_group_from_external_commit(&id)
519                    .await
520                    .unwrap();
521            })
522        })
523        .await
524    }
525
526    #[apply(all_cred_cipher)]
527    #[wasm_bindgen_test]
528    async fn should_fail_when_no_pending_external_commit(case: TestCase) {
529        run_test_with_central(case.clone(), move |[central]| {
530            Box::pin(async move {
531                let id = conversation_id();
532                // try to merge an inexisting pending group
533                let merge_unknown = central.context.merge_pending_group_from_external_commit(&id).await;
534
535                assert!(matches!(
536                    merge_unknown.unwrap_err(),
537                    crate::CryptoError::KeyStoreError(CryptoKeystoreError::MissingKeyInStore(
538                        MissingKeyErrorKind::MlsPendingGroup
539                    ))
540                ));
541            })
542        })
543        .await
544    }
545
546    #[apply(all_cred_cipher)]
547    #[wasm_bindgen_test]
548    async fn should_return_valid_group_info(case: TestCase) {
549        run_test_with_client_ids(
550            case.clone(),
551            ["alice", "bob", "charlie"],
552            move |[alice_central, bob_central, charlie_central]| {
553                Box::pin(async move {
554                    let id = conversation_id();
555                    alice_central
556                        .context
557                        .new_conversation(&id, case.credential_type, case.cfg.clone())
558                        .await
559                        .unwrap();
560
561                    // export Alice group info
562                    let group_info = alice_central.get_group_info(&id).await;
563
564                    // Bob tries to join Alice's group
565                    let MlsConversationInitBundle {
566                        commit: bob_external_commit,
567                        group_info,
568                        ..
569                    } = bob_central
570                        .context
571                        .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
572                        .await
573                        .unwrap();
574
575                    // Alice decrypts the commit, Bob's in !
576                    alice_central
577                        .context
578                        .decrypt_message(&id, &bob_external_commit.to_bytes().unwrap())
579                        .await
580                        .unwrap();
581                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
582
583                    // Bob merges the commit, he's also in !
584                    bob_central
585                        .context
586                        .merge_pending_group_from_external_commit(&id)
587                        .await
588                        .unwrap();
589                    assert!(bob_central.context.get_conversation(&id).await.is_ok());
590                    assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
591                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
592
593                    // Now charlie wants to join with the [GroupInfo] from Bob's external commit
594                    let bob_gi = group_info.get_group_info();
595                    let MlsConversationInitBundle {
596                        commit: charlie_external_commit,
597                        ..
598                    } = charlie_central
599                        .context
600                        .join_by_external_commit(bob_gi, case.custom_cfg(), case.credential_type)
601                        .await
602                        .unwrap();
603
604                    // Both Alice & Bob decrypt the commit
605                    alice_central
606                        .context
607                        .decrypt_message(&id, charlie_external_commit.to_bytes().unwrap())
608                        .await
609                        .unwrap();
610                    bob_central
611                        .context
612                        .decrypt_message(&id, charlie_external_commit.to_bytes().unwrap())
613                        .await
614                        .unwrap();
615                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
616                    assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
617
618                    // Charlie merges the commit, he's also in !
619                    charlie_central
620                        .context
621                        .merge_pending_group_from_external_commit(&id)
622                        .await
623                        .unwrap();
624                    assert!(charlie_central.context.get_conversation(&id).await.is_ok());
625                    assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
626                    assert!(charlie_central.try_talk_to(&id, &alice_central).await.is_ok());
627                    assert!(charlie_central.try_talk_to(&id, &bob_central).await.is_ok());
628                })
629            },
630        )
631        .await
632    }
633
634    #[apply(all_cred_cipher)]
635    #[wasm_bindgen_test]
636    async fn should_fail_when_sender_user_not_in_group(case: TestCase) {
637        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
638            Box::pin(async move {
639                let id = conversation_id();
640
641                alice_central
642                    .context
643                    .set_callbacks(Some(Arc::new(ValidationCallbacks {
644                        client_is_existing_group_user: false,
645                        ..Default::default()
646                    })))
647                    .await
648                    .unwrap();
649
650                alice_central
651                    .context
652                    .new_conversation(&id, case.credential_type, case.cfg.clone())
653                    .await
654                    .unwrap();
655
656                // export Alice group info
657                let group_info = alice_central.get_group_info(&id).await;
658
659                // Bob tries to join Alice's group
660                let MlsConversationInitBundle { commit, .. } = bob_central
661                    .context
662                    .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
663                    .await
664                    .unwrap();
665                let alice_accepts_ext_commit = alice_central
666                    .context
667                    .decrypt_message(&id, &commit.to_bytes().unwrap())
668                    .await;
669                assert!(matches!(
670                    alice_accepts_ext_commit.unwrap_err(),
671                    CryptoError::UnauthorizedExternalCommit
672                ))
673            })
674        })
675        .await
676    }
677
678    #[apply(all_cred_cipher)]
679    #[wasm_bindgen_test]
680    async fn should_fail_when_sender_lacks_role(case: TestCase) {
681        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
682            Box::pin(async move {
683                let id = conversation_id();
684
685                alice_central
686                    .context
687                    .set_callbacks(Some(Arc::new(ValidationCallbacks {
688                        user_authorize: false,
689                        ..Default::default()
690                    })))
691                    .await
692                    .unwrap();
693
694                alice_central
695                    .context
696                    .new_conversation(&id, case.credential_type, case.cfg.clone())
697                    .await
698                    .unwrap();
699
700                // export Alice group info
701                let group_info = alice_central.get_group_info(&id).await;
702
703                // Bob tries to join Alice's group
704                let MlsConversationInitBundle { commit, .. } = bob_central
705                    .context
706                    .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
707                    .await
708                    .unwrap();
709                let alice_accepts_ext_commit = alice_central
710                    .context
711                    .decrypt_message(&id, &commit.to_bytes().unwrap())
712                    .await;
713                assert!(matches!(
714                    alice_accepts_ext_commit.unwrap_err(),
715                    CryptoError::UnauthorizedExternalCommit
716                ))
717            })
718        })
719        .await
720    }
721
722    #[apply(all_cred_cipher)]
723    #[wasm_bindgen_test]
724    async fn clear_pending_group_should_succeed(case: TestCase) {
725        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
726            Box::pin(async move {
727                let id = conversation_id();
728                alice_central
729                    .context
730                    .new_conversation(&id, case.credential_type, case.cfg.clone())
731                    .await
732                    .unwrap();
733
734                let initial_count = alice_central.context.count_entities().await;
735
736                // export Alice group info
737                let group_info = alice_central.get_group_info(&id).await;
738
739                // Bob tries to join Alice's group
740                bob_central
741                    .context
742                    .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
743                    .await
744                    .unwrap();
745
746                // But for some reason, Bob wants to abort joining the group
747                bob_central
748                    .context
749                    .clear_pending_group_from_external_commit(&id)
750                    .await
751                    .unwrap();
752
753                let final_count = alice_central.context.count_entities().await;
754                assert_eq!(initial_count, final_count);
755
756                // Hence trying to merge the pending should fail
757                let result = bob_central.context.merge_pending_group_from_external_commit(&id).await;
758                assert!(matches!(
759                    result.unwrap_err(),
760                    CryptoError::KeyStoreError(CryptoKeystoreError::MissingKeyInStore(
761                        MissingKeyErrorKind::MlsPendingGroup
762                    ))
763                ))
764            })
765        })
766        .await
767    }
768
769    #[apply(all_cred_cipher)]
770    #[wasm_bindgen_test]
771    async fn new_with_inflight_join_should_fail_when_already_exists(case: TestCase) {
772        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
773            Box::pin(async move {
774                let id = conversation_id();
775                alice_central
776                    .context
777                    .new_conversation(&id, case.credential_type, case.cfg.clone())
778                    .await
779                    .unwrap();
780                let gi = alice_central.get_group_info(&id).await;
781
782                // Bob to join a conversation but while the server processes its request he
783                // creates a conversation with the id of the conversation he's trying to join
784                bob_central
785                    .context
786                    .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
787                    .await
788                    .unwrap();
789                // erroneous call
790                let conflict_join = bob_central
791                    .context
792                    .new_conversation(&id, case.credential_type, case.cfg.clone())
793                    .await;
794                assert!(matches!(conflict_join.unwrap_err(), CryptoError::ConversationAlreadyExists(i) if i == id));
795            })
796        })
797        .await
798    }
799
800    #[apply(all_cred_cipher)]
801    #[wasm_bindgen_test]
802    async fn new_with_inflight_welcome_should_fail_when_already_exists(case: TestCase) {
803        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
804            Box::pin(async move {
805                let id = conversation_id();
806                alice_central
807                    .context
808                    .new_conversation(&id, case.credential_type, case.cfg.clone())
809                    .await
810                    .unwrap();
811                let gi = alice_central.get_group_info(&id).await;
812
813                // While Bob tries to join a conversation via external commit he's also invited
814                // to a conversation with the same id through a Welcome message
815                bob_central
816                    .context
817                    .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
818                    .await
819                    .unwrap();
820
821                let bob = bob_central.rand_key_package(&case).await;
822                let welcome = alice_central
823                    .context
824                    .add_members_to_conversation(&id, vec![bob])
825                    .await
826                    .unwrap()
827                    .welcome;
828
829                // erroneous call
830                let conflict_welcome = bob_central
831                    .context
832                    .process_welcome_message(welcome.into(), case.custom_cfg())
833                    .await;
834
835                assert!(matches!(conflict_welcome.unwrap_err(), CryptoError::ConversationAlreadyExists(i) if i == id));
836            })
837        })
838        .await
839    }
840
841    #[apply(all_cred_cipher)]
842    #[wasm_bindgen_test]
843    async fn should_fail_when_invalid_group_info(case: TestCase) {
844        run_test_with_client_ids(
845            case.clone(),
846            ["alice", "bob", "guest"],
847            move |[alice_central, bob_central, guest_central]| {
848                Box::pin(async move {
849                    let expiration_time = 14;
850                    let start = fluvio_wasm_timer::Instant::now();
851                    let id = conversation_id();
852                    alice_central
853                        .context
854                        .new_conversation(&id, case.credential_type, case.cfg.clone())
855                        .await
856                        .unwrap();
857
858                    let invalid_kp = bob_central.new_keypackage(&case, Lifetime::new(expiration_time)).await;
859                    alice_central
860                        .context
861                        .add_members_to_conversation(&id, vec![invalid_kp.into()])
862                        .await
863                        .unwrap();
864                    alice_central.context.commit_accepted(&id).await.unwrap();
865
866                    let elapsed = start.elapsed();
867                    // Give time to the certificate to expire
868                    let expiration_time = core::time::Duration::from_secs(expiration_time);
869                    if expiration_time > elapsed {
870                        async_std::task::sleep(expiration_time - elapsed + core::time::Duration::from_secs(1)).await;
871                    }
872
873                    let group_info = alice_central.get_group_info(&id).await;
874
875                    let join_ext_commit = guest_central
876                        .context
877                        .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
878                        .await;
879
880                    // TODO: currently succeeds as we don't anymore validate KeyPackage lifetime upon reception: find another way to craft an invalid KeyPackage. Tracking issue: WPB-9596
881                    join_ext_commit.unwrap();
882                    /*assert!(matches!(
883                        join_ext_commit.unwrap_err(),
884                        CryptoError::MlsError(MlsError::MlsExternalCommitError(ExternalCommitError::PublicGroupError(
885                            CreationFromExternalError::TreeSyncError(TreeSyncFromNodesError::LeafNodeValidationError(
886                                LeafNodeValidationError::Lifetime(LifetimeError::NotCurrent),
887                            )),
888                        )))
889                    ));*/
890                })
891            },
892        )
893        .await
894    }
895
896    #[apply(all_cred_cipher)]
897    #[wasm_bindgen_test]
898    async fn group_should_have_right_config(case: TestCase) {
899        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
900            Box::pin(async move {
901                let id = conversation_id();
902                alice_central
903                    .context
904                    .new_conversation(&id, case.credential_type, case.cfg.clone())
905                    .await
906                    .unwrap();
907
908                let gi = alice_central.get_group_info(&id).await;
909                bob_central
910                    .context
911                    .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
912                    .await
913                    .unwrap();
914                bob_central
915                    .context
916                    .merge_pending_group_from_external_commit(&id)
917                    .await
918                    .unwrap();
919                let group = bob_central.get_conversation_unchecked(&id).await;
920
921                let capabilities = group.group.group_context_extensions().required_capabilities().unwrap();
922
923                // see https://www.rfc-editor.org/rfc/rfc9420.html#section-11.1
924                assert!(capabilities.extension_types().is_empty());
925                assert!(capabilities.proposal_types().is_empty());
926                assert_eq!(
927                    capabilities.credential_types(),
928                    MlsConversationConfiguration::DEFAULT_SUPPORTED_CREDENTIALS
929                );
930            })
931        })
932        .await
933    }
934}