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: TestContext) {
99            let [owner_central, guest_central] = case.sessions().await;
100            Box::pin(async move {
101                let id = conversation_id();
102                owner_central
103                    .transaction
104                    .new_conversation(&id, case.credential_type, case.cfg.clone())
105                    .await
106                    .unwrap();
107                let epoch = owner_central.get_conversation_unchecked(&id).await.group.epoch();
108
109                // Craft an external proposal from guest
110                let external_add = guest_central
111                    .transaction
112                    .new_external_add_proposal(id.clone(), epoch, case.ciphersuite(), case.credential_type)
113                    .await
114                    .unwrap();
115
116                // Owner receives external proposal message from server
117                let decrypted = owner_central
118                    .transaction
119                    .conversation(&id)
120                    .await
121                    .unwrap()
122                    .decrypt_message(external_add.to_bytes().unwrap())
123                    .await
124                    .unwrap();
125                // just owner for now
126                assert_eq!(owner_central.get_conversation_unchecked(&id).await.members().len(), 1);
127
128                // verify Guest's (sender) identity
129                guest_central.verify_sender_identity(&case, &decrypted).await;
130
131                // simulate commit message reception from server
132                owner_central
133                    .transaction
134                    .conversation(&id)
135                    .await
136                    .unwrap()
137                    .commit_pending_proposals()
138                    .await
139                    .unwrap();
140                // guest joined the group
141                assert_eq!(owner_central.get_conversation_unchecked(&id).await.members().len(), 2);
142
143                let welcome = guest_central.mls_transport().await.latest_welcome_message().await;
144                guest_central
145                    .transaction
146                    .process_welcome_message(welcome.into(), case.custom_cfg())
147                    .await
148                    .unwrap();
149                assert_eq!(guest_central.get_conversation_unchecked(&id).await.members().len(), 2);
150                // guest can send messages in the group
151                assert!(guest_central.try_talk_to(&id, &owner_central).await.is_ok());
152            })
153            .await
154        }
155    }
156
157    mod remove {
158        use super::*;
159        use crate::{MlsErrorKind, prelude::MlsError};
160        use openmls::prelude::{
161            ExternalProposal, GroupId, MlsMessageIn, ProcessMessageError, SenderExtensionIndex, ValidationError,
162        };
163
164        #[apply(all_cred_cipher)]
165        #[wasm_bindgen_test]
166        async fn ds_should_remove_guest_from_conversation(case: TestContext) {
167            let [owner, guest, ds] = case.sessions().await;
168            Box::pin(async move {
169                let owner_central = &owner.transaction;
170                let guest_central = &guest.transaction;
171                let id = conversation_id();
172
173                let ds_signature_key = ds.client_signature_key(&case).await.as_slice().to_vec();
174                let mut cfg = case.cfg.clone();
175                owner_central
176                    .set_raw_external_senders(&mut cfg, vec![ds_signature_key])
177                    .await
178                    .unwrap();
179                owner_central
180                    .new_conversation(&id, case.credential_type, cfg)
181                    .await
182                    .unwrap();
183
184                owner.invite_all(&case, &id, [&guest]).await.unwrap();
185                assert_eq!(owner.get_conversation_unchecked(&id).await.members().len(), 2);
186
187                // now, as e.g. a Delivery Service, let's create an external remove proposal
188                // and kick guest out of the conversation
189                let to_remove = owner.index_of(&id, guest.get_client_id().await).await;
190                let sender_index = SenderExtensionIndex::new(0);
191
192                let (sc, ct) = (case.signature_scheme(), case.credential_type);
193                let cb = ds.find_most_recent_credential_bundle(sc, ct).await.unwrap();
194
195                let group_id = GroupId::from_slice(&id[..]);
196                let epoch = owner.get_conversation_unchecked(&id).await.group.epoch();
197                let proposal =
198                    ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index).unwrap();
199
200                owner_central
201                    .conversation(&id)
202                    .await
203                    .unwrap()
204                    .decrypt_message(proposal.to_bytes().unwrap())
205                    .await
206                    .unwrap();
207                guest_central
208                    .conversation(&id)
209                    .await
210                    .unwrap()
211                    .decrypt_message(proposal.to_bytes().unwrap())
212                    .await
213                    .unwrap();
214                owner_central
215                    .conversation(&id)
216                    .await
217                    .unwrap()
218                    .commit_pending_proposals()
219                    .await
220                    .unwrap();
221                let commit = owner.mls_transport().await.latest_commit().await;
222
223                assert_eq!(owner.get_conversation_unchecked(&id).await.members().len(), 1);
224
225                // guest can no longer participate
226                guest_central
227                    .conversation(&id)
228                    .await
229                    .unwrap()
230                    .decrypt_message(commit.to_bytes().unwrap())
231                    .await
232                    .unwrap();
233                assert!(guest_central.conversation(&id).await.is_err());
234                assert!(guest.try_talk_to(&id, &owner).await.is_err());
235            })
236            .await
237        }
238
239        #[apply(all_cred_cipher)]
240        #[wasm_bindgen_test]
241        async fn should_fail_when_invalid_external_sender(case: TestContext) {
242            use crate::mls;
243
244            let [owner, guest, ds, attacker] = case.sessions().await;
245            Box::pin(async move {
246                let id = conversation_id();
247                // Delivery service key is used in the group..
248                let ds_signature_key = ds.client_signature_key(&case).await.as_slice().to_vec();
249                let mut cfg = case.cfg.clone();
250                owner
251                    .transaction
252                    .set_raw_external_senders(&mut cfg, vec![ds_signature_key])
253                    .await
254                    .unwrap();
255                owner
256                    .transaction
257                    .new_conversation(&id, case.credential_type, cfg)
258                    .await
259                    .unwrap();
260
261                owner.invite_all(&case, &id, [&guest]).await.unwrap();
262                assert_eq!(owner.get_conversation_unchecked(&id).await.members().len(), 2);
263
264                // now, attacker will try to remove guest from the group, and should fail
265                let to_remove = owner.index_of(&id, guest.get_client_id().await).await;
266                let sender_index = SenderExtensionIndex::new(1);
267
268                let (sc, ct) = (case.signature_scheme(), case.credential_type);
269                let cb = attacker.find_most_recent_credential_bundle(sc, ct).await.unwrap();
270                let group_id = GroupId::from_slice(&id[..]);
271                let epoch = owner.get_conversation_unchecked(&id).await.group.epoch();
272                let proposal =
273                    ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index).unwrap();
274
275                let owner_decrypt = owner
276                    .transaction
277                    .conversation(&id)
278                    .await
279                    .unwrap()
280                    .decrypt_message(proposal.to_bytes().unwrap())
281                    .await;
282
283                assert!(matches!(
284                    owner_decrypt.unwrap_err(),
285                    mls::conversation::Error::Mls(MlsError {
286                        source: MlsErrorKind::MlsMessageError(ProcessMessageError::ValidationError(
287                            ValidationError::UnauthorizedExternalSender
288                        )),
289                        ..
290                    })
291                ));
292
293                let guest_decrypt = owner
294                    .transaction
295                    .conversation(&id)
296                    .await
297                    .unwrap()
298                    .decrypt_message(proposal.to_bytes().unwrap())
299                    .await;
300                assert!(matches!(
301                    guest_decrypt.unwrap_err(),
302                    mls::conversation::Error::Mls(MlsError {
303                        source: MlsErrorKind::MlsMessageError(ProcessMessageError::ValidationError(
304                            ValidationError::UnauthorizedExternalSender
305                        )),
306                        ..
307                    })
308                ));
309            })
310            .await
311        }
312
313        #[apply(all_cred_cipher)]
314        #[wasm_bindgen_test]
315        async fn should_fail_when_wrong_signature_key(case: TestContext) {
316            use crate::mls;
317
318            let [owner, guest, ds] = case.sessions().await;
319            Box::pin(async move {
320                let id = conversation_id();
321
322                // Here we're going to add the Delivery Service's (DS) signature key to the
323                // external senders list. However, for the purpose of this test, we will
324                // intentionally _not_ use that key when generating the remove proposal below.
325                let key = ds.client_signature_key(&case).await.as_slice().to_vec();
326                let mut cfg = case.cfg.clone();
327                owner
328                    .transaction
329                    .set_raw_external_senders(&mut cfg, vec![key.as_slice().to_vec()])
330                    .await
331                    .unwrap();
332                owner
333                    .transaction
334                    .new_conversation(&id, case.credential_type, cfg)
335                    .await
336                    .unwrap();
337
338                owner.invite_all(&case, &id, [&guest]).await.unwrap();
339                assert_eq!(owner.get_conversation_unchecked(&id).await.members().len(), 2);
340
341                let to_remove = owner.index_of(&id, guest.get_client_id().await).await;
342                let sender_index = SenderExtensionIndex::new(0);
343
344                let (sc, ct) = (case.signature_scheme(), case.credential_type);
345                // Intentionally use the guest's credential, and therefore the guest's signature
346                // key when generating the proposal so that the signature verification fails.
347                let cb = guest.find_most_recent_credential_bundle(sc, ct).await.unwrap();
348                let group_id = GroupId::from_slice(&id[..]);
349                let epoch = owner.get_conversation_unchecked(&id).await.group.epoch();
350                let proposal =
351                    ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index).unwrap();
352
353                let owner_decrypt = owner
354                    .transaction
355                    .conversation(&id)
356                    .await
357                    .unwrap()
358                    .decrypt_message(proposal.to_bytes().unwrap())
359                    .await;
360                assert!(matches!(
361                    owner_decrypt.unwrap_err(),
362                    mls::conversation::Error::Mls(MlsError {
363                        source: MlsErrorKind::MlsMessageError(ProcessMessageError::InvalidSignature),
364                        ..
365                    })
366                ));
367
368                let guest_decrypt = owner
369                    .transaction
370                    .conversation(&id)
371                    .await
372                    .unwrap()
373                    .decrypt_message(proposal.to_bytes().unwrap())
374                    .await;
375                assert!(matches!(
376                    guest_decrypt.unwrap_err(),
377                    mls::conversation::Error::Mls(MlsError {
378                        source: MlsErrorKind::MlsMessageError(ProcessMessageError::InvalidSignature),
379                        ..
380                    })
381                ));
382            })
383            .await
384        }
385
386        #[apply(all_cred_cipher)]
387        #[wasm_bindgen_test]
388        async fn joiners_from_welcome_can_accept_external_remove_proposals(case: TestContext) {
389            let [alice, bob, charlie, ds] = case.sessions().await;
390            Box::pin(async move {
391                let alice_central = &alice.transaction;
392                let bob_central = &bob.transaction;
393                let charlie_central = &charlie.transaction;
394                let id = conversation_id();
395
396                let ds_signature_key = ds.client_signature_key(&case).await.as_slice().to_vec();
397                let mut cfg = case.cfg.clone();
398                alice_central
399                    .set_raw_external_senders(&mut cfg, vec![ds_signature_key])
400                    .await
401                    .unwrap();
402
403                alice_central
404                    .new_conversation(&id, case.credential_type, cfg)
405                    .await
406                    .unwrap();
407
408                alice.invite_all(&case, &id, [&bob]).await.unwrap();
409                assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
410
411                // Charlie joins through a Welcome and should get external_senders from Welcome
412                // message and not from configuration
413                let charlie_kp = charlie.rand_key_package(&case).await;
414                alice_central
415                    .conversation(&id)
416                    .await
417                    .unwrap()
418                    .add_members(vec![charlie_kp])
419                    .await
420                    .unwrap();
421                let welcome = alice.mls_transport().await.latest_welcome_message().await;
422                let commit = alice.mls_transport().await.latest_commit().await;
423                bob_central
424                    .conversation(&id)
425                    .await
426                    .unwrap()
427                    .decrypt_message(commit.to_bytes().unwrap())
428                    .await
429                    .unwrap();
430                // Purposely have a configuration without `external_senders`
431                charlie_central
432                    .process_welcome_message(MlsMessageIn::from(welcome), case.custom_cfg())
433                    .await
434                    .unwrap();
435                assert_eq!(charlie.get_conversation_unchecked(&id).await.members().len(), 3);
436                assert!(charlie.try_talk_to(&id, &alice).await.is_ok());
437                assert!(charlie.try_talk_to(&id, &bob).await.is_ok());
438
439                // now, as e.g. a Delivery Service, let's create an external remove proposal
440                // and kick Bob out of the conversation
441                let to_remove = alice.index_of(&id, bob.get_client_id().await).await;
442                let sender_index = SenderExtensionIndex::new(0);
443                let (sc, ct) = (case.signature_scheme(), case.credential_type);
444                let cb = ds.find_most_recent_credential_bundle(sc, ct).await.unwrap();
445                let group_id = GroupId::from_slice(&id[..]);
446                let epoch = alice.get_conversation_unchecked(&id).await.group.epoch();
447                let proposal =
448                    ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index).unwrap();
449
450                // joiner from Welcome should be able to verify the external remove proposal since
451                // it has fetched back the external_sender from Welcome
452                let charlie_can_verify_ext_proposal = charlie_central
453                    .conversation(&id)
454                    .await
455                    .unwrap()
456                    .decrypt_message(proposal.to_bytes().unwrap())
457                    .await;
458                assert!(charlie_can_verify_ext_proposal.is_ok());
459
460                alice_central
461                    .conversation(&id)
462                    .await
463                    .unwrap()
464                    .decrypt_message(proposal.to_bytes().unwrap())
465                    .await
466                    .unwrap();
467                bob_central
468                    .conversation(&id)
469                    .await
470                    .unwrap()
471                    .decrypt_message(proposal.to_bytes().unwrap())
472                    .await
473                    .unwrap();
474
475                charlie_central
476                    .conversation(&id)
477                    .await
478                    .unwrap()
479                    .commit_pending_proposals()
480                    .await
481                    .unwrap();
482                let commit = charlie.mls_transport().await.latest_commit().await;
483                assert_eq!(charlie.get_conversation_unchecked(&id).await.members().len(), 2);
484
485                alice_central
486                    .conversation(&id)
487                    .await
488                    .unwrap()
489                    .decrypt_message(commit.to_bytes().unwrap())
490                    .await
491                    .unwrap();
492                assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
493                bob_central
494                    .conversation(&id)
495                    .await
496                    .unwrap()
497                    .decrypt_message(commit.to_bytes().unwrap())
498                    .await
499                    .unwrap();
500                assert!(alice.try_talk_to(&id, &charlie).await.is_ok());
501                assert!(alice.try_talk_to(&id, &bob).await.is_err());
502            })
503            .await
504        }
505
506        #[apply(all_cred_cipher)]
507        #[wasm_bindgen_test]
508        async fn joiners_from_external_commit_can_accept_external_remove_proposals(case: TestContext) {
509            let [alice, bob, charlie, ds] = case.sessions().await;
510            Box::pin(async move {
511                let alice_central = &alice.transaction;
512                let bob_central = &bob.transaction;
513                let charlie_central = &charlie.transaction;
514                let id = conversation_id();
515
516                let ds_signature_key = ds.client_signature_key(&case).await.as_slice().to_vec();
517                let mut cfg = case.cfg.clone();
518                alice_central
519                    .set_raw_external_senders(&mut cfg, vec![ds_signature_key])
520                    .await
521                    .unwrap();
522
523                alice_central
524                    .new_conversation(&id, case.credential_type, cfg)
525                    .await
526                    .unwrap();
527
528                alice.invite_all(&case, &id, [&bob]).await.unwrap();
529                assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
530
531                // Charlie joins through an external commit and should get external_senders
532                // from PGS and not from configuration
533                let public_group_state = alice.get_group_info(&id).await;
534                charlie_central
535                    .join_by_external_commit(public_group_state, case.custom_cfg(), case.credential_type)
536                    .await
537                    .unwrap();
538                let commit = charlie.mls_transport().await.latest_commit().await;
539
540                // Purposely have a configuration without `external_senders`
541                alice_central
542                    .conversation(&id)
543                    .await
544                    .unwrap()
545                    .decrypt_message(commit.to_bytes().unwrap())
546                    .await
547                    .unwrap();
548                bob_central
549                    .conversation(&id)
550                    .await
551                    .unwrap()
552                    .decrypt_message(commit.to_bytes().unwrap())
553                    .await
554                    .unwrap();
555
556                assert_eq!(charlie.get_conversation_unchecked(&id).await.members().len(), 3);
557                assert!(charlie.try_talk_to(&id, &alice).await.is_ok());
558                assert!(charlie.try_talk_to(&id, &bob).await.is_ok());
559
560                // now, as e.g. a Delivery Service, let's create an external remove proposal
561                // and kick Bob out of the conversation
562                let to_remove = alice.index_of(&id, bob.get_client_id().await).await;
563                let sender_index = SenderExtensionIndex::new(0);
564                let (sc, ct) = (case.signature_scheme(), case.credential_type);
565                let cb = ds.find_most_recent_credential_bundle(sc, ct).await.unwrap();
566                let group_id = GroupId::from_slice(&id[..]);
567                let epoch = alice.get_conversation_unchecked(&id).await.group.epoch();
568                let proposal =
569                    ExternalProposal::new_remove(to_remove, group_id, epoch, &cb.signature_key, sender_index).unwrap();
570
571                // joiner from external commit should be able to verify the external remove proposal
572                // since it has fetched back the external_sender from external commit
573                let charlie_can_verify_ext_proposal = charlie_central
574                    .conversation(&id)
575                    .await
576                    .unwrap()
577                    .decrypt_message(proposal.to_bytes().unwrap())
578                    .await;
579                assert!(charlie_can_verify_ext_proposal.is_ok());
580
581                alice_central
582                    .conversation(&id)
583                    .await
584                    .unwrap()
585                    .decrypt_message(proposal.to_bytes().unwrap())
586                    .await
587                    .unwrap();
588                bob_central
589                    .conversation(&id)
590                    .await
591                    .unwrap()
592                    .decrypt_message(proposal.to_bytes().unwrap())
593                    .await
594                    .unwrap();
595
596                charlie_central
597                    .conversation(&id)
598                    .await
599                    .unwrap()
600                    .commit_pending_proposals()
601                    .await
602                    .unwrap();
603                assert_eq!(charlie.get_conversation_unchecked(&id).await.members().len(), 2);
604
605                let commit = charlie.mls_transport().await.latest_commit().await;
606                alice_central
607                    .conversation(&id)
608                    .await
609                    .unwrap()
610                    .decrypt_message(commit.to_bytes().unwrap())
611                    .await
612                    .unwrap();
613                assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
614                bob_central
615                    .conversation(&id)
616                    .await
617                    .unwrap()
618                    .decrypt_message(commit.to_bytes().unwrap())
619                    .await
620                    .unwrap();
621                assert!(alice.try_talk_to(&id, &charlie).await.is_ok());
622                assert!(alice.try_talk_to(&id, &bob).await.is_err());
623            })
624            .await
625        }
626    }
627}