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(
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 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 let group_info = alice.get_group_info(&id).await;
182
183 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 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 bob.verify_sender_identity(&case, &decrypted).await;
202
203 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 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 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 let group_info = alice_central.get_group_info(&id).await;
251
252 bob_central
254 .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
255 .await;
256 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 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 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_central
317 .transaction
318 .conversation(&id)
319 .await
320 .unwrap()
321 .update_key_material()
322 .await
323 .unwrap();
324
325 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_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 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 let group_info = alice_central.get_group_info(&id).await;
398
399 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_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 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 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 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 let group_info = alice_central.get_group_info(&id).await;
480
481 let (_, mut pending_conversation) = bob_central
483 .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
484 .await;
485
486 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_central
512 .transaction
513 .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
514 .await
515 .unwrap();
516 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 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 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 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 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 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}