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