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 match pending_conversation.send_commit(commit_bundle).await {
52 Ok(()) => {
53 pending_conversation
54 .merge()
55 .await
56 .map_err(RecursiveError::mls_conversation("merging from external commit"))?;
57 }
58 Err(e @ mls::conversation::Error::MessageRejected { .. }) => {
59 pending_conversation
60 .clear()
61 .await
62 .map_err(RecursiveError::mls_conversation("clearing external commit"))?;
63 return Err(RecursiveError::mls_conversation("sending commit")(e).into());
64 }
65 Err(e) => return Err(RecursiveError::mls_conversation("sending commit")(e).into()),
66 };
67
68 Ok(welcome_bundle)
69 }
70
71 pub(crate) async fn create_external_join_commit(
72 &self,
73 group_info: VerifiableGroupInfo,
74 custom_cfg: MlsCustomConfiguration,
75 credential_type: MlsCredentialType,
76 ) -> Result<(MlsCommitBundle, WelcomeBundle, PendingConversation)> {
77 let client = &self.session().await?;
78
79 let cs: MlsCiphersuite = group_info.ciphersuite().into();
80 let mls_provider = self.mls_provider().await?;
81 let cb = client
82 .get_most_recent_or_create_credential_bundle(&mls_provider, cs.signature_algorithm(), credential_type)
83 .await
84 .map_err(RecursiveError::mls_client("getting or creating credential bundle"))?;
85
86 let configuration = MlsConversationConfiguration {
87 ciphersuite: cs,
88 custom: custom_cfg.clone(),
89 ..Default::default()
90 };
91
92 let (group, commit, group_info) = MlsGroup::join_by_external_commit(
93 &mls_provider,
94 &cb.signature_key,
95 None,
96 group_info,
97 &configuration
98 .as_openmls_default_configuration()
99 .map_err(RecursiveError::mls_conversation(
100 "using configuration as openmls default configuration",
101 ))?,
102 &[],
103 cb.to_mls_credential_with_key(),
104 )
105 .await
106 .map_err(MlsError::wrap("joining mls group by external commit"))?;
107
108 let group_info = group_info.ok_or(LeafError::MissingGroupInfo)?;
110 let group_info = MlsGroupInfoBundle::try_new_full_plaintext(group_info).map_err(
111 RecursiveError::mls_conversation("trying new full plaintext group info bundle"),
112 )?;
113
114 let crl_new_distribution_points = get_new_crl_distribution_points(
115 &mls_provider,
116 extract_crl_uris_from_group(&group)
117 .map_err(RecursiveError::mls_credential("extracting crl uris from group"))?,
118 )
119 .await
120 .map_err(RecursiveError::mls_credential("getting new crl distribution points"))?;
121
122 let new_group_id = group.group_id().to_vec();
123
124 let pending_conversation = PendingConversation::from_mls_group(group, custom_cfg, self.clone())
125 .map_err(RecursiveError::mls_conversation("creating pending conversation"))?;
126 pending_conversation
127 .save()
128 .await
129 .map_err(RecursiveError::mls_conversation("saving pending conversation"))?;
130
131 let commit_bundle = MlsCommitBundle {
132 welcome: None,
133 commit,
134 group_info,
135 };
136
137 let welcome_bundle = WelcomeBundle {
138 id: new_group_id,
139 crl_new_distribution_points,
140 };
141
142 Ok((commit_bundle, welcome_bundle, pending_conversation))
143 }
144
145 pub(crate) async fn pending_conversation_exists(&self, id: &ConversationId) -> Result<bool> {
146 match self.pending_conversation(id).await {
147 Ok(_) => Ok(true),
148 Err(Error::Leaf(LeafError::ConversationNotFound(_))) => Ok(false),
149 Err(e) => Err(e),
150 }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use openmls::prelude::*;
157 use wasm_bindgen_test::*;
158
159 use core_crypto_keystore::{CryptoKeystoreError, CryptoKeystoreMls, MissingKeyErrorKind};
160
161 use super::Error;
162 use crate::{
163 LeafError,
164 prelude::{MlsConversationConfiguration, WelcomeBundle},
165 test_utils::*,
166 transaction_context,
167 };
168
169 wasm_bindgen_test_configure!(run_in_browser);
170
171 #[apply(all_cred_cipher)]
172 #[wasm_bindgen_test]
173 async fn join_by_external_commit_should_succeed(case: TestContext) {
174 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice, bob]| {
175 Box::pin(async move {
176 let id = conversation_id();
177 alice
178 .transaction
179 .new_conversation(&id, case.credential_type, case.cfg.clone())
180 .await
181 .unwrap();
182
183 let group_info = alice.get_group_info(&id).await;
185
186 let (external_commit, mut pending_conversation) = bob
188 .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
189 .await;
190
191 assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 1);
193 let decrypted = alice
194 .transaction
195 .conversation(&id)
196 .await
197 .unwrap()
198 .decrypt_message(&external_commit.commit.to_bytes().unwrap())
199 .await
200 .unwrap();
201 assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
202
203 bob.verify_sender_identity(&case, &decrypted).await;
205
206 assert!(bob.transaction.conversation(&id).await.is_err());
209 pending_conversation.merge().await.unwrap();
210 assert!(bob.transaction.conversation(&id).await.is_ok());
211 assert_eq!(bob.get_conversation_unchecked(&id).await.members().len(), 2);
212 assert!(alice.try_talk_to(&id, &bob).await.is_ok());
213
214 let error = bob
216 .transaction
217 .keystore()
218 .await
219 .unwrap()
220 .mls_pending_groups_load(&id)
221 .await;
222 assert!(matches!(
223 error.unwrap_err(),
224 CryptoKeystoreError::MissingKeyInStore(MissingKeyErrorKind::MlsPendingGroup)
225 ));
226
227 bob.transaction
229 .conversation(&id)
230 .await
231 .unwrap()
232 .drop_and_restore()
233 .await;
234 assert!(bob.try_talk_to(&id, &alice).await.is_ok());
235 })
236 })
237 .await
238 }
239
240 #[apply(all_cred_cipher)]
241 #[wasm_bindgen_test]
242 async fn join_by_external_commit_should_be_retriable(case: TestContext) {
243 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
244 Box::pin(async move {
245 let id = conversation_id();
246 alice_central
247 .transaction
248 .new_conversation(&id, case.credential_type, case.cfg.clone())
249 .await
250 .unwrap();
251
252 let group_info = alice_central.get_group_info(&id).await;
254
255 bob_central
257 .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
258 .await;
259 let WelcomeBundle {
265 id: conversation_id, ..
266 } = bob_central
267 .transaction
268 .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
269 .await
270 .unwrap();
271 assert_eq!(conversation_id.as_slice(), &id);
272 assert!(bob_central.transaction.conversation(&id).await.is_ok());
273 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
274
275 let external_commit = bob_central.mls_transport.latest_commit().await;
276 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
278 alice_central
279 .transaction
280 .conversation(&id)
281 .await
282 .unwrap()
283 .decrypt_message(&external_commit.to_bytes().unwrap())
284 .await
285 .unwrap();
286 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
287
288 assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
289 })
290 })
291 .await
292 }
293
294 #[apply(all_cred_cipher)]
295 #[wasm_bindgen_test]
296 async fn should_fail_when_bad_epoch(case: TestContext) {
297 use crate::mls;
298
299 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
300 Box::pin(async move {
301 let id = conversation_id();
302 alice_central
303 .transaction
304 .new_conversation(&id, case.credential_type, case.cfg.clone())
305 .await
306 .unwrap();
307
308 let group_info = alice_central.get_group_info(&id).await;
309 bob_central
311 .transaction
312 .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
313 .await
314 .unwrap();
315
316 let external_commit = bob_central.mls_transport.latest_commit().await;
317
318 alice_central
320 .transaction
321 .conversation(&id)
322 .await
323 .unwrap()
324 .update_key_material()
325 .await
326 .unwrap();
327
328 let result = alice_central
331 .transaction
332 .conversation(&id)
333 .await
334 .unwrap()
335 .decrypt_message(&external_commit.to_bytes().unwrap())
336 .await;
337 assert!(matches!(result.unwrap_err(), mls::conversation::Error::StaleCommit));
338 })
339 })
340 .await
341 }
342
343 #[apply(all_cred_cipher)]
344 #[wasm_bindgen_test]
345 async fn existing_clients_can_join(case: TestContext) {
346 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
347 Box::pin(async move {
348 let id = conversation_id();
349 alice_central
350 .transaction
351 .new_conversation(&id, case.credential_type, case.cfg.clone())
352 .await
353 .unwrap();
354 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
355 let group_info = alice_central.get_group_info(&id).await;
356 alice_central
358 .transaction
359 .join_by_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
360 .await
361 .unwrap();
362 })
363 })
364 .await
365 }
366
367 #[apply(all_cred_cipher)]
368 #[wasm_bindgen_test]
369 async fn should_fail_when_no_pending_external_commit(case: TestContext) {
370 let [session] = case.sessions().await;
371 let non_existent_id = conversation_id();
372 let err = session
374 .transaction
375 .pending_conversation(&non_existent_id)
376 .await
377 .unwrap_err();
378
379 assert!(matches!(
380 err, Error::Leaf(LeafError::ConversationNotFound(id)) if non_existent_id == id
381 ));
382 }
383
384 #[apply(all_cred_cipher)]
385 #[wasm_bindgen_test]
386 async fn should_return_valid_group_info(case: TestContext) {
387 run_test_with_client_ids(
388 case.clone(),
389 ["alice", "bob", "charlie"],
390 move |[alice_central, bob_central, charlie_central]| {
391 Box::pin(async move {
392 let id = conversation_id();
393 alice_central
394 .transaction
395 .new_conversation(&id, case.credential_type, case.cfg.clone())
396 .await
397 .unwrap();
398
399 let group_info = alice_central.get_group_info(&id).await;
401
402 bob_central
404 .transaction
405 .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
406 .await
407 .unwrap();
408
409 let bob_external_commit = bob_central.mls_transport.latest_commit().await;
410 assert!(bob_central.transaction.conversation(&id).await.is_ok());
411 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
412
413 alice_central
415 .transaction
416 .conversation(&id)
417 .await
418 .unwrap()
419 .decrypt_message(&bob_external_commit.to_bytes().unwrap())
420 .await
421 .unwrap();
422 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
423 assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
424
425 let group_info = bob_central.mls_transport.latest_group_info().await;
427 let bob_gi = group_info.get_group_info();
428 charlie_central
429 .transaction
430 .join_by_external_commit(bob_gi, case.custom_cfg(), case.credential_type)
431 .await
432 .unwrap();
433
434 let charlie_external_commit = charlie_central.mls_transport.latest_commit().await;
435
436 alice_central
438 .transaction
439 .conversation(&id)
440 .await
441 .unwrap()
442 .decrypt_message(charlie_external_commit.to_bytes().unwrap())
443 .await
444 .unwrap();
445 bob_central
446 .transaction
447 .conversation(&id)
448 .await
449 .unwrap()
450 .decrypt_message(charlie_external_commit.to_bytes().unwrap())
451 .await
452 .unwrap();
453 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
454 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
455
456 assert!(charlie_central.transaction.conversation(&id).await.is_ok());
458 assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
459 assert!(charlie_central.try_talk_to(&id, &alice_central).await.is_ok());
460 assert!(charlie_central.try_talk_to(&id, &bob_central).await.is_ok());
461 })
462 },
463 )
464 .await
465 }
466
467 #[apply(all_cred_cipher)]
468 #[wasm_bindgen_test]
469 async fn clear_pending_group_should_succeed(case: TestContext) {
470 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
471 Box::pin(async move {
472 let id = conversation_id();
473 alice_central
474 .transaction
475 .new_conversation(&id, case.credential_type, case.cfg.clone())
476 .await
477 .unwrap();
478
479 let initial_count = alice_central.transaction.count_entities().await;
480
481 let group_info = alice_central.get_group_info(&id).await;
483
484 let (_, mut pending_conversation) = bob_central
486 .create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
487 .await;
488
489 pending_conversation.clear().await.unwrap();
491
492 let final_count = alice_central.transaction.count_entities().await;
493 assert_eq!(initial_count, final_count);
494 })
495 })
496 .await
497 }
498
499 #[apply(all_cred_cipher)]
500 #[wasm_bindgen_test]
501 async fn new_with_inflight_join_should_fail_when_already_exists(case: TestContext) {
502 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
503 Box::pin(async move {
504 let id = conversation_id();
505 alice_central
506 .transaction
507 .new_conversation(&id, case.credential_type, case.cfg.clone())
508 .await
509 .unwrap();
510 let gi = alice_central.get_group_info(&id).await;
511
512 bob_central
515 .transaction
516 .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
517 .await
518 .unwrap();
519 let conflict_join = bob_central
521 .transaction
522 .new_conversation(&id, case.credential_type, case.cfg.clone())
523 .await;
524 assert!(matches!(
525 conflict_join.unwrap_err(),
526
527 Error::Leaf(LeafError::ConversationAlreadyExists(i))
528 if i == id
529 ));
530 })
531 })
532 .await
533 }
534
535 #[apply(all_cred_cipher)]
536 #[wasm_bindgen_test]
537 async fn new_with_inflight_welcome_should_fail_when_already_exists(case: TestContext) {
538 use crate::mls;
539
540 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
541 Box::pin(async move {
542 let id = conversation_id();
543 alice_central
544 .transaction
545 .new_conversation(&id, case.credential_type, case.cfg.clone())
546 .await
547 .unwrap();
548 let gi = alice_central.get_group_info(&id).await;
549
550 bob_central
553 .transaction
554 .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
555 .await
556 .unwrap();
557
558 let bob = bob_central.rand_key_package(&case).await;
559 alice_central
560 .transaction
561 .conversation(&id)
562 .await
563 .unwrap()
564 .add_members(vec![bob])
565 .await
566 .unwrap();
567
568 let welcome = alice_central.mls_transport.latest_welcome_message().await;
569 let conflict_welcome = bob_central
571 .transaction
572 .process_welcome_message(welcome.into(), case.custom_cfg())
573 .await;
574
575 assert!(matches!(
576 conflict_welcome.unwrap_err(),
577 transaction_context::Error::Recursive(crate::RecursiveError::MlsConversation { source, .. })
578 if matches!(*source, mls::conversation::Error::Leaf(LeafError::ConversationAlreadyExists(ref i)) if i == &id
579 )
580 ));
581 })
582 })
583 .await
584 }
585
586 #[apply(all_cred_cipher)]
587 #[wasm_bindgen_test]
588 async fn should_fail_when_invalid_group_info(case: TestContext) {
589 run_test_with_client_ids(
590 case.clone(),
591 ["alice", "bob", "guest"],
592 move |[alice_central, bob_central, guest_central]| {
593 Box::pin(async move {
594 let expiration_time = 14;
595 let start = web_time::Instant::now();
596 let id = conversation_id();
597 alice_central
598 .transaction
599 .new_conversation(&id, case.credential_type, case.cfg.clone())
600 .await
601 .unwrap();
602
603 let invalid_kp = bob_central.new_keypackage(&case, Lifetime::new(expiration_time)).await;
604 alice_central
605 .transaction
606 .conversation(&id)
607 .await
608 .unwrap()
609 .add_members(vec![invalid_kp.into()])
610 .await
611 .unwrap();
612
613 let elapsed = start.elapsed();
614 let expiration_time = core::time::Duration::from_secs(expiration_time);
616 if expiration_time > elapsed {
617 async_std::task::sleep(expiration_time - elapsed + core::time::Duration::from_secs(1)).await;
618 }
619
620 let group_info = alice_central.get_group_info(&id).await;
621
622 let join_ext_commit = guest_central
623 .transaction
624 .join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
625 .await;
626
627 join_ext_commit.unwrap();
629 })
638 },
639 )
640 .await
641 }
642
643 #[apply(all_cred_cipher)]
644 #[wasm_bindgen_test]
645 async fn group_should_have_right_config(case: TestContext) {
646 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
647 Box::pin(async move {
648 let id = conversation_id();
649 alice_central
650 .transaction
651 .new_conversation(&id, case.credential_type, case.cfg.clone())
652 .await
653 .unwrap();
654
655 let gi = alice_central.get_group_info(&id).await;
656 let (_, mut pending_conversation) = bob_central
657 .create_unmerged_external_commit(gi, case.custom_cfg(), case.credential_type)
658 .await;
659 pending_conversation.merge().await.unwrap();
660 let group = bob_central.get_conversation_unchecked(&id).await;
661
662 let capabilities = group.group.group_context_extensions().required_capabilities().unwrap();
663
664 assert!(capabilities.extension_types().is_empty());
666 assert!(capabilities.proposal_types().is_empty());
667 assert_eq!(
668 capabilities.credential_types(),
669 MlsConversationConfiguration::DEFAULT_SUPPORTED_CREDENTIALS
670 );
671 })
672 })
673 .await
674 }
675}