core_crypto/transaction_context/conversation/
external_proposal.rs

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