1use 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 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 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 let group_info = alice.get_group_info(&id).await;
181
182 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 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 bob.verify_sender_identity(&case, &decrypted).await;
201
202 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 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 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 let group_info = alice_central.get_group_info(&id).await;
249
250 bob_central
252 .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
253 .await;
254 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 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 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_central
314 .transaction
315 .conversation(&id)
316 .await
317 .unwrap()
318 .update_key_material()
319 .await
320 .unwrap();
321
322 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_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 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 let group_info = alice_central.get_group_info(&id).await;
390
391 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_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 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 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 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 let group_info = alice_central.get_group_info(&id).await;
470
471 let (_, mut pending_conversation) = bob_central
473 .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
474 .await;
475
476 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_central
501 .transaction
502 .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
503 .await
504 .unwrap();
505 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 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 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 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 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 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}