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 openmls::prelude::{MlsGroup, group_info::VerifiableGroupInfo};
18
19use super::Result;
20use crate::mls::conversation::pending_conversation::PendingConversation;
21use crate::prelude::{MlsCommitBundle, WelcomeBundle};
22use crate::{
23    LeafError, MlsError, RecursiveError,
24    context::CentralContext,
25    mls,
26    mls::credential::crl::{extract_crl_uris_from_group, get_new_crl_distribution_points},
27    prelude::{
28        ConversationId, MlsCiphersuite, MlsConversationConfiguration, MlsCredentialType, MlsCustomConfiguration,
29        MlsGroupInfoBundle,
30    },
31};
32
33impl CentralContext {
34    /// Issues an external commit and stores the group in a temporary table. This method is
35    /// intended for example when a new client wants to join the user's existing groups.
36    /// On success this function will return the group id and a message to be fanned out to other
37    /// clients.
38    ///
39    /// If the Delivery Service accepts the external commit, you have to
40    /// [PendingConversation::merge] in order to get back
41    /// a functional MLS group. On the opposite, if it rejects it, you can either
42    /// retry by just calling again [CentralContext::join_by_external_commit].
43    ///
44    /// # Arguments
45    /// * `group_info` - a GroupInfo wrapped in a MLS message. it can be obtained by deserializing a TLS serialized `GroupInfo` object
46    /// * `custom_cfg` - configuration of the MLS conversation fetched from the Delivery Service
47    /// * `credential_type` - kind of [openmls::prelude::Credential] to use for joining this group.
48    ///   If [MlsCredentialType::Basic] is chosen and no Credential has been created yet for it,
49    ///   a new one will be generated. When [MlsCredentialType::X509] is chosen, it fails when no
50    ///   [openmls::prelude::Credential] has been created for the given Ciphersuite.
51    ///
52    /// # Returns [WelcomeBundle]
53    ///
54    /// # Errors
55    /// Errors resulting from OpenMls, the KeyStore calls and serialization
56    pub async fn join_by_external_commit(
57        &self,
58        group_info: VerifiableGroupInfo,
59        custom_cfg: MlsCustomConfiguration,
60        credential_type: MlsCredentialType,
61    ) -> Result<WelcomeBundle> {
62        let (commit_bundle, welcome_bundle, mut pending_conversation) = self
63            .create_external_join_commit(group_info, custom_cfg, credential_type)
64            .await?;
65
66        match pending_conversation.send_commit(commit_bundle).await {
67            Ok(()) => {
68                pending_conversation
69                    .merge()
70                    .await
71                    .map_err(RecursiveError::mls_conversation("merging from external commit"))?;
72            }
73            Err(e @ mls::conversation::Error::MessageRejected { .. }) => {
74                pending_conversation
75                    .clear()
76                    .await
77                    .map_err(RecursiveError::mls_conversation("clearing external commit"))?;
78                return Err(RecursiveError::mls_conversation("sending commit")(e).into());
79            }
80            Err(e) => return Err(RecursiveError::mls_conversation("sending commit")(e).into()),
81        };
82
83        Ok(welcome_bundle)
84    }
85
86    pub(crate) async fn create_external_join_commit(
87        &self,
88        group_info: VerifiableGroupInfo,
89        custom_cfg: MlsCustomConfiguration,
90        credential_type: MlsCredentialType,
91    ) -> Result<(MlsCommitBundle, WelcomeBundle, PendingConversation)> {
92        let client = &self
93            .mls_client()
94            .await
95            .map_err(RecursiveError::root("getting mls client"))?;
96
97        let cs: MlsCiphersuite = group_info.ciphersuite().into();
98        let mls_provider = self
99            .mls_provider()
100            .await
101            .map_err(RecursiveError::root("getting mls provider"))?;
102        let cb = client
103            .get_most_recent_or_create_credential_bundle(&mls_provider, cs.signature_algorithm(), credential_type)
104            .await
105            .map_err(RecursiveError::mls_client("getting or creating credential bundle"))?;
106
107        let configuration = MlsConversationConfiguration {
108            ciphersuite: cs,
109            custom: custom_cfg.clone(),
110            ..Default::default()
111        };
112
113        let (group, commit, group_info) = MlsGroup::join_by_external_commit(
114            &mls_provider,
115            &cb.signature_key,
116            None,
117            group_info,
118            &configuration
119                .as_openmls_default_configuration()
120                .map_err(RecursiveError::mls_conversation(
121                    "using configuration as openmls default configuration",
122                ))?,
123            &[],
124            cb.to_mls_credential_with_key(),
125        )
126        .await
127        .map_err(MlsError::wrap("joining mls group by external commit"))?;
128
129        // We should always have ratchet tree extension turned on hence GroupInfo should always be present
130        let group_info = group_info.ok_or(LeafError::MissingGroupInfo)?;
131        let group_info = MlsGroupInfoBundle::try_new_full_plaintext(group_info).map_err(
132            RecursiveError::mls_conversation("trying new full plaintext group info bundle"),
133        )?;
134
135        let crl_new_distribution_points = get_new_crl_distribution_points(
136            &mls_provider,
137            extract_crl_uris_from_group(&group)
138                .map_err(RecursiveError::mls_credential("extracting crl uris from group"))?,
139        )
140        .await
141        .map_err(RecursiveError::mls_credential("getting new crl distribution points"))?;
142
143        let new_group_id = group.group_id().to_vec();
144
145        let pending_conversation = PendingConversation::from_mls_group(group, custom_cfg, self.clone())
146            .map_err(RecursiveError::mls_conversation("creating pending conversation"))?;
147        pending_conversation
148            .save()
149            .await
150            .map_err(RecursiveError::mls_conversation("saving pending conversation"))?;
151
152        let commit_bundle = MlsCommitBundle {
153            welcome: None,
154            commit,
155            group_info,
156        };
157
158        let welcome_bundle = WelcomeBundle {
159            id: new_group_id,
160            crl_new_distribution_points,
161        };
162
163        Ok((commit_bundle, welcome_bundle, pending_conversation))
164    }
165
166    pub(crate) async fn pending_conversation_exists(&self, id: &ConversationId) -> Result<bool> {
167        match self.pending_conversation(id).await {
168            Ok(_) => Ok(true),
169            Err(mls::conversation::Error::Leaf(LeafError::ConversationNotFound(_))) => Ok(false),
170            Err(e) => Err(e)
171                .map_err(RecursiveError::mls_conversation("checking if pending group exists"))
172                .map_err(Into::into),
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use openmls::prelude::*;
180    use wasm_bindgen_test::*;
181
182    use core_crypto_keystore::{CryptoKeystoreError, CryptoKeystoreMls, MissingKeyErrorKind};
183
184    use crate::{
185        LeafError,
186        prelude::{MlsConversationConfiguration, WelcomeBundle},
187        test_utils::*,
188    };
189
190    wasm_bindgen_test_configure!(run_in_browser);
191
192    #[apply(all_cred_cipher)]
193    #[wasm_bindgen_test]
194    async fn join_by_external_commit_should_succeed(case: TestCase) {
195        run_test_with_client_ids(
196            case.clone(),
197            ["alice", "bob"],
198            move |[alice_central, mut bob_central]| {
199                Box::pin(async move {
200                    let id = conversation_id();
201                    alice_central
202                        .context
203                        .new_conversation(&id, case.credential_type, case.cfg.clone())
204                        .await
205                        .unwrap();
206
207                    // export Alice group info
208                    let group_info = alice_central.get_group_info(&id).await;
209
210                    // Bob tries to join Alice's group
211                    let (external_commit, mut pending_conversation) = bob_central
212                        .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
213                        .await;
214
215                    // Alice acks the request and adds the new member
216                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
217                    let decrypted = alice_central
218                        .context
219                        .conversation(&id)
220                        .await
221                        .unwrap()
222                        .decrypt_message(&external_commit.commit.to_bytes().unwrap())
223                        .await
224                        .unwrap();
225                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
226
227                    // verify Bob's (sender) identity
228                    bob_central.verify_sender_identity(&case, &decrypted).await;
229
230                    // Let's say backend accepted our external commit.
231                    // So Bob can merge the commit and update the local state
232                    assert!(bob_central.context.conversation(&id).await.is_err());
233                    pending_conversation.merge().await.unwrap();
234                    assert!(bob_central.context.conversation(&id).await.is_ok());
235                    assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
236                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
237
238                    // Pending group removed from keystore
239                    let error = bob_central
240                        .context
241                        .keystore()
242                        .await
243                        .unwrap()
244                        .mls_pending_groups_load(&id)
245                        .await;
246                    assert!(matches!(
247                        error.unwrap_err(),
248                        CryptoKeystoreError::MissingKeyInStore(MissingKeyErrorKind::MlsPendingGroup)
249                    ));
250
251                    // Ensure it's durable i.e. MLS group has been persisted
252                    bob_central.context.drop_and_restore(&id).await;
253                    assert!(bob_central.try_talk_to(&id, &alice_central).await.is_ok());
254                })
255            },
256        )
257        .await
258    }
259
260    #[apply(all_cred_cipher)]
261    #[wasm_bindgen_test]
262    async fn join_by_external_commit_should_be_retriable(case: TestCase) {
263        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
264            Box::pin(async move {
265                let id = conversation_id();
266                alice_central
267                    .context
268                    .new_conversation(&id, case.credential_type, case.cfg.clone())
269                    .await
270                    .unwrap();
271
272                // export Alice group info
273                let group_info = alice_central.get_group_info(&id).await;
274
275                // Bob tries to join Alice's group
276                bob_central
277                    .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
278                    .await;
279                // BUT for some reason the Delivery Service will reject this external commit
280                // e.g. another commit arrived meanwhile and the [GroupInfo] is no longer valid
281                // But bob doesn't receive the rejection message, so the commit is still pending
282
283                // Retrying
284                let WelcomeBundle {
285                    id: conversation_id, ..
286                } = bob_central
287                    .context
288                    .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
289                    .await
290                    .unwrap();
291                assert_eq!(conversation_id.as_slice(), &id);
292                assert!(bob_central.context.conversation(&id).await.is_ok());
293                assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
294
295                let external_commit = bob_central.mls_transport.latest_commit().await;
296                // Alice decrypts the external commit and adds Bob
297                assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
298                alice_central
299                    .context
300                    .conversation(&id)
301                    .await
302                    .unwrap()
303                    .decrypt_message(&external_commit.to_bytes().unwrap())
304                    .await
305                    .unwrap();
306                assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
307
308                assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
309            })
310        })
311        .await
312    }
313
314    #[apply(all_cred_cipher)]
315    #[wasm_bindgen_test]
316    async fn should_fail_when_bad_epoch(case: TestCase) {
317        use crate::mls;
318
319        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
320            Box::pin(async move {
321                let id = conversation_id();
322                alice_central
323                    .context
324                    .new_conversation(&id, case.credential_type, case.cfg.clone())
325                    .await
326                    .unwrap();
327
328                let group_info = alice_central.get_group_info(&id).await;
329                // try to make an external join into Alice's group
330                bob_central
331                    .context
332                    .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
333                    .await
334                    .unwrap();
335
336                let external_commit = bob_central.mls_transport.latest_commit().await;
337
338                // Alice creates a new commit before receiving the external join
339                alice_central
340                    .context
341                    .conversation(&id)
342                    .await
343                    .unwrap()
344                    .update_key_material()
345                    .await
346                    .unwrap();
347
348                // receiving the external join with outdated epoch should fail because of
349                // the wrong epoch
350                let result = alice_central
351                    .context
352                    .conversation(&id)
353                    .await
354                    .unwrap()
355                    .decrypt_message(&external_commit.to_bytes().unwrap())
356                    .await;
357                assert!(matches!(result.unwrap_err(), mls::conversation::Error::StaleCommit));
358            })
359        })
360        .await
361    }
362
363    #[apply(all_cred_cipher)]
364    #[wasm_bindgen_test]
365    async fn existing_clients_can_join(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                alice_central
370                    .context
371                    .new_conversation(&id, case.credential_type, case.cfg.clone())
372                    .await
373                    .unwrap();
374                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
375                let group_info = alice_central.get_group_info(&id).await;
376                // Alice can rejoin by external commit
377                alice_central
378                    .context
379                    .join_by_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
380                    .await
381                    .unwrap();
382            })
383        })
384        .await
385    }
386
387    #[apply(all_cred_cipher)]
388    #[wasm_bindgen_test]
389    async fn should_fail_when_no_pending_external_commit(case: TestCase) {
390        use crate::mls;
391
392        run_test_with_central(case.clone(), move |[central]| {
393            Box::pin(async move {
394                let non_existent_id = conversation_id();
395                // try to get a non-existent pending group
396                let err = central
397                    .context
398                    .pending_conversation(&non_existent_id)
399                    .await
400                    .unwrap_err();
401
402                assert!(matches!(
403                   err, mls::conversation::Error::Leaf(LeafError::ConversationNotFound(id)) if non_existent_id == id
404                ));
405            })
406        })
407        .await
408    }
409
410    #[apply(all_cred_cipher)]
411    #[wasm_bindgen_test]
412    async fn should_return_valid_group_info(case: TestCase) {
413        run_test_with_client_ids(
414            case.clone(),
415            ["alice", "bob", "charlie"],
416            move |[alice_central, bob_central, charlie_central]| {
417                Box::pin(async move {
418                    let id = conversation_id();
419                    alice_central
420                        .context
421                        .new_conversation(&id, case.credential_type, case.cfg.clone())
422                        .await
423                        .unwrap();
424
425                    // export Alice group info
426                    let group_info = alice_central.get_group_info(&id).await;
427
428                    // Bob tries to join Alice's group
429                    bob_central
430                        .context
431                        .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
432                        .await
433                        .unwrap();
434
435                    let bob_external_commit = bob_central.mls_transport.latest_commit().await;
436                    assert!(bob_central.context.conversation(&id).await.is_ok());
437                    assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
438
439                    // Alice decrypts the commit, Bob's in !
440                    alice_central
441                        .context
442                        .conversation(&id)
443                        .await
444                        .unwrap()
445                        .decrypt_message(&bob_external_commit.to_bytes().unwrap())
446                        .await
447                        .unwrap();
448                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
449                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
450
451                    // Now charlie wants to join with the [GroupInfo] from Bob's external commit
452                    let group_info = bob_central.mls_transport.latest_group_info().await;
453                    let bob_gi = group_info.get_group_info();
454                    charlie_central
455                        .context
456                        .join_by_external_commit(bob_gi, case.custom_cfg(), case.credential_type)
457                        .await
458                        .unwrap();
459
460                    let charlie_external_commit = charlie_central.mls_transport.latest_commit().await;
461
462                    // Both Alice & Bob decrypt the commit
463                    alice_central
464                        .context
465                        .conversation(&id)
466                        .await
467                        .unwrap()
468                        .decrypt_message(charlie_external_commit.to_bytes().unwrap())
469                        .await
470                        .unwrap();
471                    bob_central
472                        .context
473                        .conversation(&id)
474                        .await
475                        .unwrap()
476                        .decrypt_message(charlie_external_commit.to_bytes().unwrap())
477                        .await
478                        .unwrap();
479                    assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
480                    assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
481
482                    // Charlie is also in!
483                    assert!(charlie_central.context.conversation(&id).await.is_ok());
484                    assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
485                    assert!(charlie_central.try_talk_to(&id, &alice_central).await.is_ok());
486                    assert!(charlie_central.try_talk_to(&id, &bob_central).await.is_ok());
487                })
488            },
489        )
490        .await
491    }
492
493    #[apply(all_cred_cipher)]
494    #[wasm_bindgen_test]
495    async fn clear_pending_group_should_succeed(case: TestCase) {
496        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
497            Box::pin(async move {
498                let id = conversation_id();
499                alice_central
500                    .context
501                    .new_conversation(&id, case.credential_type, case.cfg.clone())
502                    .await
503                    .unwrap();
504
505                let initial_count = alice_central.context.count_entities().await;
506
507                // export Alice group info
508                let group_info = alice_central.get_group_info(&id).await;
509
510                // Bob tries to join Alice's group
511                let (_, mut pending_conversation) = bob_central
512                    .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
513                    .await;
514
515                // But for some reason, Bob wants to abort joining the group
516                pending_conversation.clear().await.unwrap();
517
518                let final_count = alice_central.context.count_entities().await;
519                assert_eq!(initial_count, final_count);
520            })
521        })
522        .await
523    }
524
525    #[apply(all_cred_cipher)]
526    #[wasm_bindgen_test]
527    async fn new_with_inflight_join_should_fail_when_already_exists(case: TestCase) {
528        use crate::mls;
529
530        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
531            Box::pin(async move {
532                let id = conversation_id();
533                alice_central
534                    .context
535                    .new_conversation(&id, case.credential_type, case.cfg.clone())
536                    .await
537                    .unwrap();
538                let gi = alice_central.get_group_info(&id).await;
539
540                // Bob to join a conversation but while the server processes its request he
541                // creates a conversation with the id of the conversation he's trying to join
542                bob_central
543                    .context
544                    .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
545                    .await
546                    .unwrap();
547                // erroneous call
548                let conflict_join = bob_central
549                    .context
550                    .new_conversation(&id, case.credential_type, case.cfg.clone())
551                    .await;
552                assert!(matches!(
553                    conflict_join.unwrap_err(),
554                    mls::Error::Leaf(LeafError::ConversationAlreadyExists(i))
555                    if i == id
556                ));
557            })
558        })
559        .await
560    }
561
562    #[apply(all_cred_cipher)]
563    #[wasm_bindgen_test]
564    async fn new_with_inflight_welcome_should_fail_when_already_exists(case: TestCase) {
565        use crate::mls;
566
567        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
568            Box::pin(async move {
569                let id = conversation_id();
570                alice_central
571                    .context
572                    .new_conversation(&id, case.credential_type, case.cfg.clone())
573                    .await
574                    .unwrap();
575                let gi = alice_central.get_group_info(&id).await;
576
577                // While Bob tries to join a conversation via external commit he's also invited
578                // to a conversation with the same id through a Welcome message
579                bob_central
580                    .context
581                    .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
582                    .await
583                    .unwrap();
584
585                let bob = bob_central.rand_key_package(&case).await;
586                alice_central
587                    .context
588                    .conversation(&id)
589                    .await
590                    .unwrap()
591                    .add_members(vec![bob])
592                    .await
593                    .unwrap();
594
595                let welcome = alice_central.mls_transport.latest_welcome_message().await;
596                // erroneous call
597                let conflict_welcome = bob_central
598                    .context
599                    .process_welcome_message(welcome.into(), case.custom_cfg())
600                    .await;
601
602                assert!(matches!(
603                    conflict_welcome.unwrap_err(),
604                    mls::conversation::Error::Leaf(LeafError::ConversationAlreadyExists(i))
605                    if i == id
606                ));
607            })
608        })
609        .await
610    }
611
612    #[apply(all_cred_cipher)]
613    #[wasm_bindgen_test]
614    async fn should_fail_when_invalid_group_info(case: TestCase) {
615        run_test_with_client_ids(
616            case.clone(),
617            ["alice", "bob", "guest"],
618            move |[alice_central, bob_central, guest_central]| {
619                Box::pin(async move {
620                    let expiration_time = 14;
621                    let start = web_time::Instant::now();
622                    let id = conversation_id();
623                    alice_central
624                        .context
625                        .new_conversation(&id, case.credential_type, case.cfg.clone())
626                        .await
627                        .unwrap();
628
629                    let invalid_kp = bob_central.new_keypackage(&case, Lifetime::new(expiration_time)).await;
630                    alice_central
631                        .context
632                        .conversation(&id)
633                        .await
634                        .unwrap()
635                        .add_members(vec![invalid_kp.into()])
636                        .await
637                        .unwrap();
638
639                    let elapsed = start.elapsed();
640                    // Give time to the certificate to expire
641                    let expiration_time = core::time::Duration::from_secs(expiration_time);
642                    if expiration_time > elapsed {
643                        async_std::task::sleep(expiration_time - elapsed + core::time::Duration::from_secs(1)).await;
644                    }
645
646                    let group_info = alice_central.get_group_info(&id).await;
647
648                    let join_ext_commit = guest_central
649                        .context
650                        .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
651                        .await;
652
653                    // 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
654                    join_ext_commit.unwrap();
655                    /*assert!(matches!(
656                        join_ext_commit.unwrap_err(),
657                        CryptoError::MlsError(MlsError::MlsExternalCommitError(ExternalCommitError::PublicGroupError(
658                            CreationFromExternalError::TreeSyncError(TreeSyncFromNodesError::LeafNodeValidationError(
659                                LeafNodeValidationError::Lifetime(LifetimeError::NotCurrent),
660                            )),
661                        )))
662                    ));*/
663                })
664            },
665        )
666        .await
667    }
668
669    #[apply(all_cred_cipher)]
670    #[wasm_bindgen_test]
671    async fn group_should_have_right_config(case: TestCase) {
672        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
673            Box::pin(async move {
674                let id = conversation_id();
675                alice_central
676                    .context
677                    .new_conversation(&id, case.credential_type, case.cfg.clone())
678                    .await
679                    .unwrap();
680
681                let gi = alice_central.get_group_info(&id).await;
682                let (_, mut pending_conversation) = bob_central
683                    .create_unmerged_external_commit(gi, case.custom_cfg(), case.credential_type)
684                    .await;
685                pending_conversation.merge().await.unwrap();
686                let group = bob_central.get_conversation_unchecked(&id).await;
687
688                let capabilities = group.group.group_context_extensions().required_capabilities().unwrap();
689
690                // see https://www.rfc-editor.org/rfc/rfc9420.html#section-11.1
691                assert!(capabilities.extension_types().is_empty());
692                assert!(capabilities.proposal_types().is_empty());
693                assert_eq!(
694                    capabilities.credential_types(),
695                    MlsConversationConfiguration::DEFAULT_SUPPORTED_CREDENTIALS
696                );
697            })
698        })
699        .await
700    }
701}