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