core_crypto/transaction_context/conversation/
external_commit.rs

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