use openmls::prelude::{MlsGroup, group_info::VerifiableGroupInfo};
use super::Result;
use crate::mls::conversation::pending_conversation::PendingConversation;
use crate::prelude::{MlsCommitBundle, WelcomeBundle};
use crate::{
LeafError, MlsError, RecursiveError,
context::CentralContext,
mls,
mls::credential::crl::{extract_crl_uris_from_group, get_new_crl_distribution_points},
prelude::{
ConversationId, MlsCiphersuite, MlsConversationConfiguration, MlsCredentialType, MlsCustomConfiguration,
MlsGroupInfoBundle,
},
};
impl CentralContext {
pub async fn join_by_external_commit(
&self,
group_info: VerifiableGroupInfo,
custom_cfg: MlsCustomConfiguration,
credential_type: MlsCredentialType,
) -> Result<WelcomeBundle> {
let (commit_bundle, welcome_bundle, mut pending_conversation) = self
.create_external_join_commit(group_info, custom_cfg, credential_type)
.await?;
match pending_conversation.send_commit(commit_bundle).await {
Ok(()) => {
pending_conversation
.merge()
.await
.map_err(RecursiveError::mls_conversation("merging from external commit"))?;
}
Err(e @ mls::conversation::Error::MessageRejected { .. }) => {
pending_conversation
.clear()
.await
.map_err(RecursiveError::mls_conversation("clearing external commit"))?;
return Err(RecursiveError::mls_conversation("sending commit")(e).into());
}
Err(e) => return Err(RecursiveError::mls_conversation("sending commit")(e).into()),
};
Ok(welcome_bundle)
}
pub(crate) async fn create_external_join_commit(
&self,
group_info: VerifiableGroupInfo,
custom_cfg: MlsCustomConfiguration,
credential_type: MlsCredentialType,
) -> Result<(MlsCommitBundle, WelcomeBundle, PendingConversation)> {
let client = &self
.mls_client()
.await
.map_err(RecursiveError::root("getting mls client"))?;
let cs: MlsCiphersuite = group_info.ciphersuite().into();
let mls_provider = self
.mls_provider()
.await
.map_err(RecursiveError::root("getting mls provider"))?;
let cb = client
.get_most_recent_or_create_credential_bundle(&mls_provider, cs.signature_algorithm(), credential_type)
.await
.map_err(RecursiveError::mls_client("getting or creating credential bundle"))?;
let configuration = MlsConversationConfiguration {
ciphersuite: cs,
custom: custom_cfg.clone(),
..Default::default()
};
let (group, commit, group_info) = MlsGroup::join_by_external_commit(
&mls_provider,
&cb.signature_key,
None,
group_info,
&configuration
.as_openmls_default_configuration()
.map_err(RecursiveError::mls_conversation(
"using configuration as openmls default configuration",
))?,
&[],
cb.to_mls_credential_with_key(),
)
.await
.map_err(MlsError::wrap("joining mls group by external commit"))?;
let group_info = group_info.ok_or(LeafError::MissingGroupInfo)?;
let group_info = MlsGroupInfoBundle::try_new_full_plaintext(group_info).map_err(
RecursiveError::mls_conversation("trying new full plaintext group info bundle"),
)?;
let crl_new_distribution_points = get_new_crl_distribution_points(
&mls_provider,
extract_crl_uris_from_group(&group)
.map_err(RecursiveError::mls_credential("extracting crl uris from group"))?,
)
.await
.map_err(RecursiveError::mls_credential("getting new crl distribution points"))?;
let new_group_id = group.group_id().to_vec();
let pending_conversation = PendingConversation::from_mls_group(group, custom_cfg, self.clone())
.map_err(RecursiveError::mls_conversation("creating pending conversation"))?;
pending_conversation
.save()
.await
.map_err(RecursiveError::mls_conversation("saving pending conversation"))?;
let commit_bundle = MlsCommitBundle {
welcome: None,
commit,
group_info,
};
let welcome_bundle = WelcomeBundle {
id: new_group_id,
crl_new_distribution_points,
};
Ok((commit_bundle, welcome_bundle, pending_conversation))
}
pub(crate) async fn pending_conversation_exists(&self, id: &ConversationId) -> Result<bool> {
match self.pending_conversation(id).await {
Ok(_) => Ok(true),
Err(mls::conversation::Error::Leaf(LeafError::ConversationNotFound(_))) => Ok(false),
Err(e) => Err(e)
.map_err(RecursiveError::mls_conversation("checking if pending group exists"))
.map_err(Into::into),
}
}
}
#[cfg(test)]
mod tests {
use openmls::prelude::*;
use wasm_bindgen_test::*;
use core_crypto_keystore::{CryptoKeystoreError, CryptoKeystoreMls, MissingKeyErrorKind};
use crate::{
LeafError,
prelude::{MlsConversationConfiguration, WelcomeBundle},
test_utils::*,
};
wasm_bindgen_test_configure!(run_in_browser);
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn join_by_external_commit_should_succeed(case: TestCase) {
run_test_with_client_ids(
case.clone(),
["alice", "bob"],
move |[alice_central, mut bob_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let group_info = alice_central.get_group_info(&id).await;
let (external_commit, mut pending_conversation) = bob_central
.create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
.await;
assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
let decrypted = alice_central
.context
.conversation(&id)
.await
.unwrap()
.decrypt_message(&external_commit.commit.to_bytes().unwrap())
.await
.unwrap();
assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
bob_central.verify_sender_identity(&case, &decrypted).await;
assert!(bob_central.context.conversation(&id).await.is_err());
pending_conversation.merge().await.unwrap();
assert!(bob_central.context.conversation(&id).await.is_ok());
assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
let error = bob_central
.context
.keystore()
.await
.unwrap()
.mls_pending_groups_load(&id)
.await;
assert!(matches!(
error.unwrap_err(),
CryptoKeystoreError::MissingKeyInStore(MissingKeyErrorKind::MlsPendingGroup)
));
bob_central.context.drop_and_restore(&id).await;
assert!(bob_central.try_talk_to(&id, &alice_central).await.is_ok());
})
},
)
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn join_by_external_commit_should_be_retriable(case: TestCase) {
run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let group_info = alice_central.get_group_info(&id).await;
bob_central
.create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
.await;
let WelcomeBundle {
id: conversation_id, ..
} = bob_central
.context
.join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
.await
.unwrap();
assert_eq!(conversation_id.as_slice(), &id);
assert!(bob_central.context.conversation(&id).await.is_ok());
assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
let external_commit = bob_central.mls_transport.latest_commit().await;
assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 1);
alice_central
.context
.conversation(&id)
.await
.unwrap()
.decrypt_message(&external_commit.to_bytes().unwrap())
.await
.unwrap();
assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
})
})
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn should_fail_when_bad_epoch(case: TestCase) {
use crate::mls;
run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let group_info = alice_central.get_group_info(&id).await;
bob_central
.context
.join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
.await
.unwrap();
let external_commit = bob_central.mls_transport.latest_commit().await;
alice_central
.context
.conversation(&id)
.await
.unwrap()
.update_key_material()
.await
.unwrap();
let result = alice_central
.context
.conversation(&id)
.await
.unwrap()
.decrypt_message(&external_commit.to_bytes().unwrap())
.await;
assert!(matches!(result.unwrap_err(), mls::conversation::Error::StaleCommit));
})
})
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn existing_clients_can_join(case: TestCase) {
run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
let group_info = alice_central.get_group_info(&id).await;
alice_central
.context
.join_by_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
.await
.unwrap();
})
})
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn should_fail_when_no_pending_external_commit(case: TestCase) {
use crate::mls;
run_test_with_central(case.clone(), move |[central]| {
Box::pin(async move {
let non_existent_id = conversation_id();
let err = central
.context
.pending_conversation(&non_existent_id)
.await
.unwrap_err();
assert!(matches!(
err, mls::conversation::Error::Leaf(LeafError::ConversationNotFound(id)) if non_existent_id == id
));
})
})
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn should_return_valid_group_info(case: TestCase) {
run_test_with_client_ids(
case.clone(),
["alice", "bob", "charlie"],
move |[alice_central, bob_central, charlie_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let group_info = alice_central.get_group_info(&id).await;
bob_central
.context
.join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
.await
.unwrap();
let bob_external_commit = bob_central.mls_transport.latest_commit().await;
assert!(bob_central.context.conversation(&id).await.is_ok());
assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
alice_central
.context
.conversation(&id)
.await
.unwrap()
.decrypt_message(&bob_external_commit.to_bytes().unwrap())
.await
.unwrap();
assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
let group_info = bob_central.mls_transport.latest_group_info().await;
let bob_gi = group_info.get_group_info();
charlie_central
.context
.join_by_external_commit(bob_gi, case.custom_cfg(), case.credential_type)
.await
.unwrap();
let charlie_external_commit = charlie_central.mls_transport.latest_commit().await;
alice_central
.context
.conversation(&id)
.await
.unwrap()
.decrypt_message(charlie_external_commit.to_bytes().unwrap())
.await
.unwrap();
bob_central
.context
.conversation(&id)
.await
.unwrap()
.decrypt_message(charlie_external_commit.to_bytes().unwrap())
.await
.unwrap();
assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
assert!(charlie_central.context.conversation(&id).await.is_ok());
assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
assert!(charlie_central.try_talk_to(&id, &alice_central).await.is_ok());
assert!(charlie_central.try_talk_to(&id, &bob_central).await.is_ok());
})
},
)
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn clear_pending_group_should_succeed(case: TestCase) {
run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let initial_count = alice_central.context.count_entities().await;
let group_info = alice_central.get_group_info(&id).await;
let (_, mut pending_conversation) = bob_central
.create_unmerged_external_commit(group_info.clone(), case.custom_cfg(), case.credential_type)
.await;
pending_conversation.clear().await.unwrap();
let final_count = alice_central.context.count_entities().await;
assert_eq!(initial_count, final_count);
})
})
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn new_with_inflight_join_should_fail_when_already_exists(case: TestCase) {
use crate::mls;
run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let gi = alice_central.get_group_info(&id).await;
bob_central
.context
.join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
.await
.unwrap();
let conflict_join = bob_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await;
assert!(matches!(
conflict_join.unwrap_err(),
mls::Error::Leaf(LeafError::ConversationAlreadyExists(i))
if i == id
));
})
})
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn new_with_inflight_welcome_should_fail_when_already_exists(case: TestCase) {
use crate::mls;
run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let gi = alice_central.get_group_info(&id).await;
bob_central
.context
.join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
.await
.unwrap();
let bob = bob_central.rand_key_package(&case).await;
alice_central
.context
.conversation(&id)
.await
.unwrap()
.add_members(vec![bob])
.await
.unwrap();
let welcome = alice_central.mls_transport.latest_welcome_message().await;
let conflict_welcome = bob_central
.context
.process_welcome_message(welcome.into(), case.custom_cfg())
.await;
assert!(matches!(
conflict_welcome.unwrap_err(),
mls::conversation::Error::Leaf(LeafError::ConversationAlreadyExists(i))
if i == id
));
})
})
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn should_fail_when_invalid_group_info(case: TestCase) {
run_test_with_client_ids(
case.clone(),
["alice", "bob", "guest"],
move |[alice_central, bob_central, guest_central]| {
Box::pin(async move {
let expiration_time = 14;
let start = web_time::Instant::now();
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let invalid_kp = bob_central.new_keypackage(&case, Lifetime::new(expiration_time)).await;
alice_central
.context
.conversation(&id)
.await
.unwrap()
.add_members(vec![invalid_kp.into()])
.await
.unwrap();
let elapsed = start.elapsed();
let expiration_time = core::time::Duration::from_secs(expiration_time);
if expiration_time > elapsed {
async_std::task::sleep(expiration_time - elapsed + core::time::Duration::from_secs(1)).await;
}
let group_info = alice_central.get_group_info(&id).await;
let join_ext_commit = guest_central
.context
.join_by_external_commit(group_info, case.custom_cfg(), case.credential_type)
.await;
join_ext_commit.unwrap();
})
},
)
.await
}
#[apply(all_cred_cipher)]
#[wasm_bindgen_test]
async fn group_should_have_right_config(case: TestCase) {
run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
Box::pin(async move {
let id = conversation_id();
alice_central
.context
.new_conversation(&id, case.credential_type, case.cfg.clone())
.await
.unwrap();
let gi = alice_central.get_group_info(&id).await;
let (_, mut pending_conversation) = bob_central
.create_unmerged_external_commit(gi, case.custom_cfg(), case.credential_type)
.await;
pending_conversation.merge().await.unwrap();
let group = bob_central.get_conversation_unchecked(&id).await;
let capabilities = group.group.group_context_extensions().required_capabilities().unwrap();
assert!(capabilities.extension_types().is_empty());
assert!(capabilities.proposal_types().is_empty());
assert_eq!(
capabilities.credential_types(),
MlsConversationConfiguration::DEFAULT_SUPPORTED_CREDENTIALS
);
})
})
.await
}
}