core_crypto/mls/
external_proposal.rs

1use log::trace;
2use openmls::prelude::{GroupEpoch, GroupId, JoinProposal, LeafNodeIndex, MlsMessageOut, Proposal};
3use std::collections::HashSet;
4
5use super::Result;
6use crate::{
7    LeafError, MlsError, RecursiveError,
8    context::CentralContext,
9    mls::{self, ClientId, ConversationId, credential::typ::MlsCredentialType},
10    prelude::{MlsCiphersuite, MlsConversation},
11};
12
13impl MlsConversation {
14    /// Get actual group members and subtract pending remove proposals
15    pub fn members_in_next_epoch(&self) -> Vec<ClientId> {
16        let pending_removals = self.pending_removals();
17        let existing_clients = self
18            .group
19            .members()
20            .filter_map(|kp| {
21                if !pending_removals.contains(&kp.index) {
22                    Some(kp.credential.identity().into())
23                } else {
24                    trace!(client_index:% = kp.index; "Client is pending removal");
25                    None
26                }
27            })
28            .collect::<HashSet<_>>();
29        existing_clients.into_iter().collect()
30    }
31
32    /// Gather pending remove proposals
33    fn pending_removals(&self) -> Vec<LeafNodeIndex> {
34        self.group
35            .pending_proposals()
36            .filter_map(|proposal| match proposal.proposal() {
37                Proposal::Remove(remove) => Some(remove.removed()),
38                _ => None,
39            })
40            .collect::<Vec<_>>()
41    }
42}
43
44impl CentralContext {
45    /// Crafts a new external Add proposal. Enables a client outside a group to request addition to this group.
46    /// For Wire only, the client must belong to an user already in the group
47    ///
48    /// # Arguments
49    /// * `conversation_id` - the group/conversation id
50    /// * `epoch` - the current epoch of the group. See [openmls::group::GroupEpoch]
51    /// * `ciphersuite` - of the new [openmls::prelude::KeyPackage] to create
52    /// * `credential_type` - of the new [openmls::prelude::KeyPackage] to create
53    ///
54    /// # Return type
55    /// Returns a message with the proposal to be add a new client
56    ///
57    /// # Errors
58    /// Errors resulting from the creation of the proposal within OpenMls.
59    /// Fails when `credential_type` is [MlsCredentialType::X509] and no Credential has been created
60    /// for it beforehand with [CentralContext::e2ei_mls_init_only] or variants.
61    #[cfg_attr(test, crate::dispotent)]
62    pub async fn new_external_add_proposal(
63        &self,
64        conversation_id: ConversationId,
65        epoch: GroupEpoch,
66        ciphersuite: MlsCiphersuite,
67        credential_type: MlsCredentialType,
68    ) -> Result<MlsMessageOut> {
69        let group_id = GroupId::from_slice(conversation_id.as_slice());
70        let mls_provider = self
71            .mls_provider()
72            .await
73            .map_err(RecursiveError::root("getting mls provider"))?;
74
75        let client = self
76            .mls_client()
77            .await
78            .map_err(RecursiveError::root("getting mls client"))?;
79        let cb = client
80            .find_most_recent_credential_bundle(ciphersuite.signature_algorithm(), credential_type)
81            .await;
82        let cb = match (cb, credential_type) {
83            (Ok(cb), _) => cb,
84            (Err(mls::client::Error::CredentialNotFound(_)), MlsCredentialType::Basic) => {
85                // If a Basic CredentialBundle does not exist, just create one instead of failing
86                client
87                    .init_basic_credential_bundle_if_missing(&mls_provider, ciphersuite.signature_algorithm())
88                    .await
89                    .map_err(RecursiveError::mls_client(
90                        "initializing basic credential bundle if missing",
91                    ))?;
92
93                client
94                    .find_most_recent_credential_bundle(ciphersuite.signature_algorithm(), credential_type)
95                    .await
96                    .map_err(RecursiveError::mls_client(
97                        "finding most recent credential bundle (which we just created)",
98                    ))?
99            }
100            (Err(mls::client::Error::CredentialNotFound(_)), MlsCredentialType::X509) => {
101                return Err(LeafError::E2eiEnrollmentNotDone.into());
102            }
103            (Err(e), _) => return Err(RecursiveError::mls_client("finding most recent credential bundle")(e).into()),
104        };
105        let kp = client
106            .generate_one_keypackage_from_credential_bundle(&mls_provider, ciphersuite, &cb)
107            .await
108            .map_err(RecursiveError::mls_client(
109                "generating one keypackage from credential bundle",
110            ))?;
111
112        JoinProposal::new(kp, group_id, epoch, &cb.signature_key)
113            .map_err(MlsError::wrap("creating join proposal"))
114            .map_err(Into::into)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use wasm_bindgen_test::*;
121
122    use crate::test_utils::*;
123
124    wasm_bindgen_test_configure!(run_in_browser);
125
126    mod add {
127        use super::*;
128
129        #[apply(all_cred_cipher)]
130        #[wasm_bindgen_test]
131        async fn guest_should_externally_propose_adding_itself_to_owner_group(case: TestCase) {
132            run_test_with_client_ids(
133                case.clone(),
134                ["owner", "guest"],
135                move |[owner_central, guest_central]| {
136                    Box::pin(async move {
137                        let id = conversation_id();
138                        owner_central
139                            .context
140                            .new_conversation(&id, case.credential_type, case.cfg.clone())
141                            .await
142                            .unwrap();
143                        let epoch = owner_central.get_conversation_unchecked(&id).await.group.epoch();
144
145                        // Craft an external proposal from guest
146                        let external_add = guest_central
147                            .context
148                            .new_external_add_proposal(id.clone(), epoch, case.ciphersuite(), case.credential_type)
149                            .await
150                            .unwrap();
151
152                        // Owner receives external proposal message from server
153                        let decrypted = owner_central
154                            .context
155                            .conversation(&id)
156                            .await
157                            .unwrap()
158                            .decrypt_message(external_add.to_bytes().unwrap())
159                            .await
160                            .unwrap();
161                        // just owner for now
162                        assert_eq!(owner_central.get_conversation_unchecked(&id).await.members().len(), 1);
163
164                        // verify Guest's (sender) identity
165                        guest_central.verify_sender_identity(&case, &decrypted).await;
166
167                        // simulate commit message reception from server
168                        owner_central
169                            .context
170                            .conversation(&id)
171                            .await
172                            .unwrap()
173                            .commit_pending_proposals()
174                            .await
175                            .unwrap();
176                        // guest joined the group
177                        assert_eq!(owner_central.get_conversation_unchecked(&id).await.members().len(), 2);
178
179                        let welcome = guest_central.mls_transport.latest_welcome_message().await;
180                        guest_central
181                            .context
182                            .process_welcome_message(welcome.into(), case.custom_cfg())
183                            .await
184                            .unwrap();
185                        assert_eq!(guest_central.get_conversation_unchecked(&id).await.members().len(), 2);
186                        // guest can send messages in the group
187                        assert!(guest_central.try_talk_to(&id, &owner_central).await.is_ok());
188                    })
189                },
190            )
191            .await
192        }
193    }
194
195    mod remove {
196        use super::*;
197        use crate::{MlsErrorKind, prelude::MlsError};
198        use openmls::prelude::{
199            ExternalProposal, GroupId, MlsMessageIn, ProcessMessageError, SenderExtensionIndex, ValidationError,
200        };
201
202        #[apply(all_cred_cipher)]
203        #[wasm_bindgen_test]
204        async fn ds_should_remove_guest_from_conversation(case: TestCase) {
205            run_test_with_client_ids(case.clone(), ["owner", "guest", "ds"], move |[owner, guest, ds]| {
206                Box::pin(async move {
207                    let owner_central = &owner.context;
208                    let guest_central = &guest.context;
209                    let id = conversation_id();
210
211                    let ds_signature_key = ds.client_signature_key(&case).await.as_slice().to_vec();
212                    let mut cfg = case.cfg.clone();
213                    owner_central
214                        .set_raw_external_senders(&mut cfg, vec![ds_signature_key])
215                        .await
216                        .unwrap();
217                    owner_central
218                        .new_conversation(&id, case.credential_type, cfg)
219                        .await
220                        .unwrap();
221
222                    owner.invite_all(&case, &id, [&guest]).await.unwrap();
223                    assert_eq!(owner.get_conversation_unchecked(&id).await.members().len(), 2);
224
225                    // now, as e.g. a Delivery Service, let's create an external remove proposal
226                    // and kick guest out of the conversation
227                    let to_remove = owner.index_of(&id, guest.get_client_id().await).await;
228                    let sender_index = SenderExtensionIndex::new(0);
229
230                    let (sc, ct) = (case.signature_scheme(), case.credential_type);
231                    let cb = ds.find_most_recent_credential_bundle(sc, ct).await.unwrap();
232
233                    let group_id = GroupId::from_slice(&id[..]);
234                    let epoch = owner.get_conversation_unchecked(&id).await.group.epoch();
235                    let proposal =
236                        ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index)
237                            .unwrap();
238
239                    owner_central
240                        .conversation(&id)
241                        .await
242                        .unwrap()
243                        .decrypt_message(proposal.to_bytes().unwrap())
244                        .await
245                        .unwrap();
246                    guest_central
247                        .conversation(&id)
248                        .await
249                        .unwrap()
250                        .decrypt_message(proposal.to_bytes().unwrap())
251                        .await
252                        .unwrap();
253                    owner_central
254                        .conversation(&id)
255                        .await
256                        .unwrap()
257                        .commit_pending_proposals()
258                        .await
259                        .unwrap();
260                    let commit = owner.mls_transport.latest_commit().await;
261
262                    assert_eq!(owner.get_conversation_unchecked(&id).await.members().len(), 1);
263
264                    // guest can no longer participate
265                    guest_central
266                        .conversation(&id)
267                        .await
268                        .unwrap()
269                        .decrypt_message(commit.to_bytes().unwrap())
270                        .await
271                        .unwrap();
272                    assert!(guest_central.conversation(&id).await.is_err());
273                    assert!(guest.try_talk_to(&id, &owner).await.is_err());
274                })
275            })
276            .await
277        }
278
279        #[apply(all_cred_cipher)]
280        #[wasm_bindgen_test]
281        async fn should_fail_when_invalid_external_sender(case: TestCase) {
282            use crate::mls;
283
284            run_test_with_client_ids(
285                case.clone(),
286                ["owner", "guest", "ds", "attacker"],
287                move |[owner, guest, ds, attacker]| {
288                    Box::pin(async move {
289                        let id = conversation_id();
290                        // Delivery service key is used in the group..
291                        let ds_signature_key = ds.client_signature_key(&case).await.as_slice().to_vec();
292                        let mut cfg = case.cfg.clone();
293                        owner
294                            .context
295                            .set_raw_external_senders(&mut cfg, vec![ds_signature_key])
296                            .await
297                            .unwrap();
298                        owner
299                            .context
300                            .new_conversation(&id, case.credential_type, cfg)
301                            .await
302                            .unwrap();
303
304                        owner.invite_all(&case, &id, [&guest]).await.unwrap();
305                        assert_eq!(owner.get_conversation_unchecked(&id).await.members().len(), 2);
306
307                        // now, attacker will try to remove guest from the group, and should fail
308                        let to_remove = owner.index_of(&id, guest.get_client_id().await).await;
309                        let sender_index = SenderExtensionIndex::new(1);
310
311                        let (sc, ct) = (case.signature_scheme(), case.credential_type);
312                        let cb = attacker.find_most_recent_credential_bundle(sc, ct).await.unwrap();
313                        let group_id = GroupId::from_slice(&id[..]);
314                        let epoch = owner.get_conversation_unchecked(&id).await.group.epoch();
315                        let proposal =
316                            ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index)
317                                .unwrap();
318
319                        let owner_decrypt = owner
320                            .context
321                            .conversation(&id)
322                            .await
323                            .unwrap()
324                            .decrypt_message(proposal.to_bytes().unwrap())
325                            .await;
326
327                        assert!(matches!(
328                            owner_decrypt.unwrap_err(),
329                            mls::conversation::Error::Mls(MlsError {
330                                source: MlsErrorKind::MlsMessageError(ProcessMessageError::ValidationError(
331                                    ValidationError::UnauthorizedExternalSender
332                                )),
333                                ..
334                            })
335                        ));
336
337                        let guest_decrypt = owner
338                            .context
339                            .conversation(&id)
340                            .await
341                            .unwrap()
342                            .decrypt_message(proposal.to_bytes().unwrap())
343                            .await;
344                        assert!(matches!(
345                            guest_decrypt.unwrap_err(),
346                            mls::conversation::Error::Mls(MlsError {
347                                source: MlsErrorKind::MlsMessageError(ProcessMessageError::ValidationError(
348                                    ValidationError::UnauthorizedExternalSender
349                                )),
350                                ..
351                            })
352                        ));
353                    })
354                },
355            )
356            .await
357        }
358
359        #[apply(all_cred_cipher)]
360        #[wasm_bindgen_test]
361        async fn should_fail_when_wrong_signature_key(case: TestCase) {
362            use crate::mls;
363
364            run_test_with_client_ids(case.clone(), ["owner", "guest", "ds"], move |[owner, guest, ds]| {
365                Box::pin(async move {
366                    let id = conversation_id();
367
368                    // Here we're going to add the Delivery Service's (DS) signature key to the
369                    // external senders list. However, for the purpose of this test, we will
370                    // intentionally _not_ use that key when generating the remove proposal below.
371                    let key = ds.client_signature_key(&case).await.as_slice().to_vec();
372                    let mut cfg = case.cfg.clone();
373                    owner
374                        .context
375                        .set_raw_external_senders(&mut cfg, vec![key.as_slice().to_vec()])
376                        .await
377                        .unwrap();
378                    owner
379                        .context
380                        .new_conversation(&id, case.credential_type, cfg)
381                        .await
382                        .unwrap();
383
384                    owner.invite_all(&case, &id, [&guest]).await.unwrap();
385                    assert_eq!(owner.get_conversation_unchecked(&id).await.members().len(), 2);
386
387                    let to_remove = owner.index_of(&id, guest.get_client_id().await).await;
388                    let sender_index = SenderExtensionIndex::new(0);
389
390                    let (sc, ct) = (case.signature_scheme(), case.credential_type);
391                    // Intentionally use the guest's credential, and therefore the guest's signature
392                    // key when generating the proposal so that the signature verification fails.
393                    let cb = guest.find_most_recent_credential_bundle(sc, ct).await.unwrap();
394                    let group_id = GroupId::from_slice(&id[..]);
395                    let epoch = owner.get_conversation_unchecked(&id).await.group.epoch();
396                    let proposal =
397                        ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index)
398                            .unwrap();
399
400                    let owner_decrypt = owner
401                        .context
402                        .conversation(&id)
403                        .await
404                        .unwrap()
405                        .decrypt_message(proposal.to_bytes().unwrap())
406                        .await;
407                    assert!(matches!(
408                        owner_decrypt.unwrap_err(),
409                        mls::conversation::Error::Mls(MlsError {
410                            source: MlsErrorKind::MlsMessageError(ProcessMessageError::InvalidSignature),
411                            ..
412                        })
413                    ));
414
415                    let guest_decrypt = owner
416                        .context
417                        .conversation(&id)
418                        .await
419                        .unwrap()
420                        .decrypt_message(proposal.to_bytes().unwrap())
421                        .await;
422                    assert!(matches!(
423                        guest_decrypt.unwrap_err(),
424                        mls::conversation::Error::Mls(MlsError {
425                            source: MlsErrorKind::MlsMessageError(ProcessMessageError::InvalidSignature),
426                            ..
427                        })
428                    ));
429                })
430            })
431            .await
432        }
433
434        #[apply(all_cred_cipher)]
435        #[wasm_bindgen_test]
436        async fn joiners_from_welcome_can_accept_external_remove_proposals(case: TestCase) {
437            run_test_with_client_ids(
438                case.clone(),
439                ["alice", "bob", "charlie", "ds"],
440                move |[alice, bob, charlie, ds]| {
441                    Box::pin(async move {
442                        let alice_central = &alice.context;
443                        let bob_central = &bob.context;
444                        let charlie_central = &charlie.context;
445                        let id = conversation_id();
446
447                        let ds_signature_key = ds.client_signature_key(&case).await.as_slice().to_vec();
448                        let mut cfg = case.cfg.clone();
449                        alice_central
450                            .set_raw_external_senders(&mut cfg, vec![ds_signature_key])
451                            .await
452                            .unwrap();
453
454                        alice_central
455                            .new_conversation(&id, case.credential_type, cfg)
456                            .await
457                            .unwrap();
458
459                        alice.invite_all(&case, &id, [&bob]).await.unwrap();
460                        assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
461
462                        // Charlie joins through a Welcome and should get external_senders from Welcome
463                        // message and not from configuration
464                        let charlie_kp = charlie.rand_key_package(&case).await;
465                        alice_central
466                            .conversation(&id)
467                            .await
468                            .unwrap()
469                            .add_members(vec![charlie_kp])
470                            .await
471                            .unwrap();
472                        let welcome = alice.mls_transport.latest_welcome_message().await;
473                        let commit = alice.mls_transport.latest_commit().await;
474                        bob_central
475                            .conversation(&id)
476                            .await
477                            .unwrap()
478                            .decrypt_message(commit.to_bytes().unwrap())
479                            .await
480                            .unwrap();
481                        // Purposely have a configuration without `external_senders`
482                        charlie_central
483                            .process_welcome_message(MlsMessageIn::from(welcome), case.custom_cfg())
484                            .await
485                            .unwrap();
486                        assert_eq!(charlie.get_conversation_unchecked(&id).await.members().len(), 3);
487                        assert!(charlie.try_talk_to(&id, &alice).await.is_ok());
488                        assert!(charlie.try_talk_to(&id, &bob).await.is_ok());
489
490                        // now, as e.g. a Delivery Service, let's create an external remove proposal
491                        // and kick Bob out of the conversation
492                        let to_remove = alice.index_of(&id, bob.get_client_id().await).await;
493                        let sender_index = SenderExtensionIndex::new(0);
494                        let (sc, ct) = (case.signature_scheme(), case.credential_type);
495                        let cb = ds.find_most_recent_credential_bundle(sc, ct).await.unwrap();
496                        let group_id = GroupId::from_slice(&id[..]);
497                        let epoch = alice.get_conversation_unchecked(&id).await.group.epoch();
498                        let proposal =
499                            ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index)
500                                .unwrap();
501
502                        // joiner from Welcome should be able to verify the external remove proposal since
503                        // it has fetched back the external_sender from Welcome
504                        let charlie_can_verify_ext_proposal = charlie_central
505                            .conversation(&id)
506                            .await
507                            .unwrap()
508                            .decrypt_message(proposal.to_bytes().unwrap())
509                            .await;
510                        assert!(charlie_can_verify_ext_proposal.is_ok());
511
512                        alice_central
513                            .conversation(&id)
514                            .await
515                            .unwrap()
516                            .decrypt_message(proposal.to_bytes().unwrap())
517                            .await
518                            .unwrap();
519                        bob_central
520                            .conversation(&id)
521                            .await
522                            .unwrap()
523                            .decrypt_message(proposal.to_bytes().unwrap())
524                            .await
525                            .unwrap();
526
527                        charlie_central
528                            .conversation(&id)
529                            .await
530                            .unwrap()
531                            .commit_pending_proposals()
532                            .await
533                            .unwrap();
534                        let commit = charlie.mls_transport.latest_commit().await;
535                        assert_eq!(charlie.get_conversation_unchecked(&id).await.members().len(), 2);
536
537                        alice_central
538                            .conversation(&id)
539                            .await
540                            .unwrap()
541                            .decrypt_message(commit.to_bytes().unwrap())
542                            .await
543                            .unwrap();
544                        assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
545                        bob_central
546                            .conversation(&id)
547                            .await
548                            .unwrap()
549                            .decrypt_message(commit.to_bytes().unwrap())
550                            .await
551                            .unwrap();
552                        assert!(alice.try_talk_to(&id, &charlie).await.is_ok());
553                        assert!(alice.try_talk_to(&id, &bob).await.is_err());
554                    })
555                },
556            )
557            .await
558        }
559
560        #[apply(all_cred_cipher)]
561        #[wasm_bindgen_test]
562        async fn joiners_from_external_commit_can_accept_external_remove_proposals(case: TestCase) {
563            run_test_with_client_ids(
564                case.clone(),
565                ["alice", "bob", "charlie", "ds"],
566                move |[alice, bob, charlie, ds]| {
567                    Box::pin(async move {
568                        let alice_central = &alice.context;
569                        let bob_central = &bob.context;
570                        let charlie_central = &charlie.context;
571                        let id = conversation_id();
572
573                        let ds_signature_key = ds.client_signature_key(&case).await.as_slice().to_vec();
574                        let mut cfg = case.cfg.clone();
575                        alice_central
576                            .set_raw_external_senders(&mut cfg, vec![ds_signature_key])
577                            .await
578                            .unwrap();
579
580                        alice_central
581                            .new_conversation(&id, case.credential_type, cfg)
582                            .await
583                            .unwrap();
584
585                        alice.invite_all(&case, &id, [&bob]).await.unwrap();
586                        assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
587
588                        // Charlie joins through an external commit and should get external_senders
589                        // from PGS and not from configuration
590                        let public_group_state = alice.get_group_info(&id).await;
591                        charlie_central
592                            .join_by_external_commit(public_group_state, case.custom_cfg(), case.credential_type)
593                            .await
594                            .unwrap();
595                        let commit = charlie.mls_transport.latest_commit().await;
596
597                        // Purposely have a configuration without `external_senders`
598                        alice_central
599                            .conversation(&id)
600                            .await
601                            .unwrap()
602                            .decrypt_message(commit.to_bytes().unwrap())
603                            .await
604                            .unwrap();
605                        bob_central
606                            .conversation(&id)
607                            .await
608                            .unwrap()
609                            .decrypt_message(commit.to_bytes().unwrap())
610                            .await
611                            .unwrap();
612
613                        assert_eq!(charlie.get_conversation_unchecked(&id).await.members().len(), 3);
614                        assert!(charlie.try_talk_to(&id, &alice).await.is_ok());
615                        assert!(charlie.try_talk_to(&id, &bob).await.is_ok());
616
617                        // now, as e.g. a Delivery Service, let's create an external remove proposal
618                        // and kick Bob out of the conversation
619                        let to_remove = alice.index_of(&id, bob.get_client_id().await).await;
620                        let sender_index = SenderExtensionIndex::new(0);
621                        let (sc, ct) = (case.signature_scheme(), case.credential_type);
622                        let cb = ds.find_most_recent_credential_bundle(sc, ct).await.unwrap();
623                        let group_id = GroupId::from_slice(&id[..]);
624                        let epoch = alice.get_conversation_unchecked(&id).await.group.epoch();
625                        let proposal =
626                            ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index)
627                                .unwrap();
628
629                        // joiner from external commit should be able to verify the external remove proposal
630                        // since it has fetched back the external_sender from external commit
631                        let charlie_can_verify_ext_proposal = charlie_central
632                            .conversation(&id)
633                            .await
634                            .unwrap()
635                            .decrypt_message(proposal.to_bytes().unwrap())
636                            .await;
637                        assert!(charlie_can_verify_ext_proposal.is_ok());
638
639                        alice_central
640                            .conversation(&id)
641                            .await
642                            .unwrap()
643                            .decrypt_message(proposal.to_bytes().unwrap())
644                            .await
645                            .unwrap();
646                        bob_central
647                            .conversation(&id)
648                            .await
649                            .unwrap()
650                            .decrypt_message(proposal.to_bytes().unwrap())
651                            .await
652                            .unwrap();
653
654                        charlie_central
655                            .conversation(&id)
656                            .await
657                            .unwrap()
658                            .commit_pending_proposals()
659                            .await
660                            .unwrap();
661                        assert_eq!(charlie.get_conversation_unchecked(&id).await.members().len(), 2);
662
663                        let commit = charlie.mls_transport.latest_commit().await;
664                        alice_central
665                            .conversation(&id)
666                            .await
667                            .unwrap()
668                            .decrypt_message(commit.to_bytes().unwrap())
669                            .await
670                            .unwrap();
671                        assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
672                        bob_central
673                            .conversation(&id)
674                            .await
675                            .unwrap()
676                            .decrypt_message(commit.to_bytes().unwrap())
677                            .await
678                            .unwrap();
679                        assert!(alice.try_talk_to(&id, &charlie).await.is_ok());
680                        assert!(alice.try_talk_to(&id, &bob).await.is_err());
681                    })
682                },
683            )
684            .await
685        }
686    }
687}