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