Skip to main content

core_crypto/mls/conversation/
mod.rs

1//! MLS groups (aka conversation) are the actual entities cementing all the participants in a
2//! conversation.
3//!
4//! This table summarizes what operations are permitted on a group depending its state:
5//! *(PP=pending proposal, PC=pending commit)*
6//!
7//! | can I ?   | 0 PP / 0 PC | 1+ PP / 0 PC | 0 PP / 1 PC | 1+ PP / 1 PC |
8//! |-----------|-------------|--------------|-------------|--------------|
9//! | encrypt   | ✅           | ❌            | ❌           | ❌            |
10//! | handshake | ✅           | ✅            | ❌           | ❌            |
11//! | merge     | ❌           | ❌            | ✅           | ✅            |
12//! | decrypt   | ✅           | ✅            | ✅           | ✅            |
13
14pub(crate) mod commit;
15mod commit_delay;
16pub(crate) mod config;
17pub(crate) mod conversation_guard;
18mod credential;
19mod duplicate;
20#[cfg(test)]
21mod durability;
22mod error;
23pub(crate) mod group_info;
24mod id;
25mod immutable_conversation;
26pub(crate) mod merge;
27mod orphan_welcome;
28mod own_commit;
29pub(crate) mod pending_conversation;
30mod persistence;
31pub(crate) mod proposal;
32mod welcome;
33mod wipe;
34
35use std::{
36    borrow::Borrow,
37    collections::{HashMap, HashSet},
38    ops::Deref,
39    sync::Arc,
40};
41
42use core_crypto_keystore::Database;
43use itertools::Itertools as _;
44use log::trace;
45use openmls::{
46    group::{MlsGroup, QueuedProposal},
47    prelude::{LeafNode, LeafNodeIndex, Proposal, Sender},
48};
49use openmls_traits::OpenMlsCryptoProvider;
50
51use self::config::MlsConversationConfiguration;
52pub use self::{
53    conversation_guard::ConversationGuard,
54    error::{Error, Result},
55    id::{ConversationId, ConversationIdRef},
56    immutable_conversation::ImmutableConversation,
57    welcome::WelcomeMessage,
58};
59use super::credential::Credential;
60use crate::{
61    CipherSuite, ClientId, ClientIdRef, CredentialRef, CredentialType, E2eiConversationState, LeafError, MlsError,
62    RecursiveError, UserId, WireIdentity, bytes_wrapper,
63    mls::{HasSessionAndCrypto, Session, credential::ext::CredentialExt as _},
64    mls_provider::MlsCryptoProvider,
65};
66
67bytes_wrapper!(
68    /// A secret key derived from the group secret.
69    ///
70    /// This is intended to be used for AVS.
71    #[derive(Clone)]
72    SecretKey
73);
74
75bytes_wrapper!(
76    /// The raw public key of an external sender.
77    ///
78    /// This can be used to initialize a subconversation.
79    #[derive(Clone)]
80    ExternalSenderKey
81);
82
83/// The base layer for [Conversation].
84/// The trait is only exposed internally.
85#[cfg_attr(target_os = "unknown", async_trait::async_trait(?Send))]
86#[cfg_attr(not(target_os = "unknown"), async_trait::async_trait)]
87pub(crate) trait ConversationWithMls<'a> {
88    /// [`Session`] and [`TransactionContext`][crate::transaction_context::TransactionContext] both implement
89    /// [`HasSessionAndCrypto`].
90    type Context: HasSessionAndCrypto;
91
92    type Conversation: Deref<Target = MlsConversation> + Send;
93
94    async fn context(&self) -> Result<Self::Context>;
95
96    async fn conversation(&'a self) -> Self::Conversation;
97
98    async fn crypto_provider(&self) -> Result<MlsCryptoProvider> {
99        self.context()
100            .await?
101            .crypto_provider()
102            .await
103            .map_err(RecursiveError::mls("getting mls provider"))
104            .map_err(Into::into)
105    }
106
107    async fn session(&self) -> Result<Session<Database>> {
108        self.context()
109            .await?
110            .session()
111            .await
112            .map_err(RecursiveError::mls("getting mls client"))
113            .map_err(Into::into)
114    }
115}
116
117/// The `Conversation` trait provides a set of operations that can be done on
118/// an **immutable** conversation.
119// We keep the super trait internal intentionally, as it is not meant to be used by the public API,
120// hence #[expect(private_bounds)].
121#[expect(private_bounds)]
122#[cfg_attr(target_os = "unknown", async_trait::async_trait(?Send))]
123#[cfg_attr(not(target_os = "unknown"), async_trait::async_trait)]
124pub trait Conversation<'a>: ConversationWithMls<'a> {
125    /// Returns the epoch of a given conversation
126    async fn epoch(&'a self) -> u64 {
127        self.conversation().await.group().epoch().as_u64()
128    }
129
130    /// Returns the ciphersuite of a given conversation
131    async fn ciphersuite(&'a self) -> CipherSuite {
132        self.conversation().await.ciphersuite()
133    }
134
135    /// Returns the credential ref to a credential of a given conversation
136    async fn credential_ref(&'a self) -> Result<CredentialRef> {
137        let inner = self.conversation().await;
138        let session = self.session().await?;
139        let credential = inner
140            .find_current_credential(&session)
141            .await
142            .map_err(|_| Error::IdentityInitializationError)?;
143        Ok(CredentialRef::from_credential(&credential))
144    }
145
146    /// Derives a new key from the one in the group, to be used elsewhere.
147    ///
148    /// # Arguments
149    /// * `key_length` - the length of the key to be derived. If the value is higher than the bounds of `u16` or the
150    ///   context hash * 255, an error will be returned
151    ///
152    /// # Errors
153    /// OpenMls secret generation error
154    async fn export_secret_key(&'a self, key_length: usize) -> Result<SecretKey> {
155        const EXPORTER_LABEL: &str = "exporter";
156        const EXPORTER_CONTEXT: &[u8] = &[];
157        let backend = self.crypto_provider().await?;
158        let inner = self.conversation().await;
159        inner
160            .group()
161            .export_secret(&backend, EXPORTER_LABEL, EXPORTER_CONTEXT, key_length)
162            .map(Into::into)
163            .map_err(MlsError::wrap("exporting secret key"))
164            .map_err(Into::into)
165    }
166
167    /// Exports the clients from a conversation
168    ///
169    /// # Arguments
170    /// * `conversation_id` - the group/conversation id
171    async fn get_client_ids(&'a self) -> Vec<ClientId> {
172        let inner = self.conversation().await;
173        inner
174            .group()
175            .members()
176            .map(|kp| ClientId::from(kp.credential.identity().to_owned()))
177            .collect()
178    }
179
180    /// Returns the raw public key of the single external sender present in this group.
181    /// This should be used to initialize a subconversation
182    async fn get_external_sender(&'a self) -> Result<ExternalSenderKey> {
183        let inner = self.conversation().await;
184        let ext_senders = inner
185            .group()
186            .group_context_extensions()
187            .external_senders()
188            .ok_or(Error::MissingExternalSenderExtension)?;
189        let ext_sender = ext_senders.first().ok_or(Error::MissingExternalSenderExtension)?;
190        let ext_sender_public_key = ext_sender.signature_key().as_slice().to_vec().into();
191        Ok(ext_sender_public_key)
192    }
193
194    /// Indicates when to mark a conversation as not verified i.e. when not all its members have a X509
195    /// Credential generated by Wire's end-to-end identity enrollment
196    async fn e2ei_conversation_state(&'a self) -> Result<E2eiConversationState> {
197        let backend = self.crypto_provider().await?;
198        let authentication_service = backend.authentication_service();
199        authentication_service.refresh_time_of_interest().await;
200        let inner = self.conversation().await;
201        let state = Session::<Database>::compute_conversation_state(
202            inner.ciphersuite(),
203            inner.group.members_credentials(),
204            CredentialType::X509,
205            authentication_service.borrow().await.as_ref(),
206        )
207        .await;
208        Ok(state)
209    }
210
211    /// From a given conversation, get the identity of the members supplied. Identity is only present for
212    /// members with a Certificate Credential (after turning on end-to-end identity).
213    /// If no member has a x509 certificate, it will return an empty Vec
214    async fn get_device_identities(
215        &'a self,
216        device_ids: &[impl Borrow<ClientIdRef> + Sync],
217    ) -> Result<Vec<WireIdentity>> {
218        if device_ids.is_empty() {
219            return Err(Error::CallerError(
220                "This function accepts a list of IDs as a parameter, but that list was empty.",
221            ));
222        }
223        let mls_provider = self.crypto_provider().await?;
224        let auth_service = mls_provider.authentication_service();
225        auth_service.refresh_time_of_interest().await;
226        let auth_service = auth_service.borrow().await;
227        let env = auth_service.as_ref();
228        let conversation = self.conversation().await;
229        conversation
230            .members_with_key()
231            .into_iter()
232            .filter(|(id, _)| device_ids.iter().any(|client_id| client_id.borrow() == id))
233            .map(|(_, c)| {
234                c.extract_identity(conversation.ciphersuite(), env)
235                    .map_err(RecursiveError::mls_credential("extracting identity"))
236            })
237            .collect::<Result<Vec<_>, _>>()
238            .map_err(Into::into)
239    }
240
241    /// From a given conversation, get the identity of the users (device holders) supplied.
242    /// Identity is only present for devices with a Certificate Credential (after turning on end-to-end identity).
243    /// If no member has a x509 certificate, it will return an empty Vec.
244    ///
245    /// Returns a Map with all the identities for a given users. Consumers are then recommended to
246    /// reduce those identities to determine the actual status of a user.
247    async fn get_user_identities(&'a self, user_ids: &[String]) -> Result<HashMap<String, Vec<WireIdentity>>> {
248        if user_ids.is_empty() {
249            return Err(Error::CallerError(
250                "This function accepts a list of IDs as a parameter, but that list was empty.",
251            ));
252        }
253        let mls_provider = self.crypto_provider().await?;
254        let auth_service = mls_provider.authentication_service();
255        auth_service.refresh_time_of_interest().await;
256        let auth_service = auth_service.borrow().await;
257        let env = auth_service.as_ref();
258        let conversation = self.conversation().await;
259        let user_ids = user_ids.iter().map(|uid| uid.as_bytes()).collect::<Vec<_>>();
260
261        conversation
262            .members_with_key()
263            .iter()
264            .filter_map(|(id, c)| UserId::try_from(id.as_slice()).ok().zip(Some(c)))
265            .filter(|(uid, _)| user_ids.contains(uid))
266            .map(|(uid, c)| {
267                let uid = String::try_from(uid).map_err(RecursiveError::mls_client("getting user identities"))?;
268                let identity = c
269                    .extract_identity(conversation.ciphersuite(), env)
270                    .map_err(RecursiveError::mls_credential("extracting identity"))?;
271                Ok((uid, identity))
272            })
273            .process_results(|iter| iter.into_group_map())
274    }
275
276    /// Generate a new [`crate::HistorySecret`].
277    ///
278    /// This is useful when it's this client's turn to generate a new history client.
279    ///
280    /// The generated secret is cryptographically unrelated to the current CoreCrypto client.
281    async fn generate_history_secret(&'a self) -> Result<crate::HistorySecret> {
282        let ciphersuite = self.ciphersuite().await;
283        crate::ephemeral::generate_history_secret(ciphersuite)
284            .await
285            .map_err(RecursiveError::root("generating history secret"))
286            .map_err(Into::into)
287    }
288
289    /// Check if history sharing is enabled, i.e., if any of the conversation members have a [ClientId] starting
290    /// with [crate::HISTORY_CLIENT_ID_PREFIX].
291    async fn is_history_sharing_enabled(&'a self) -> bool {
292        self.get_client_ids()
293            .await
294            .iter()
295            .any(|client_id| client_id.starts_with(crate::ephemeral::HISTORY_CLIENT_ID_PREFIX.as_bytes()))
296    }
297}
298
299impl<'a, T: ConversationWithMls<'a>> Conversation<'a> for T {}
300
301/// This is a wrapper on top of the OpenMls's [MlsGroup], that provides Core Crypto specific functionality
302///
303/// This type will store the state of a group. With the [MlsGroup] it holds, it provides all
304/// operations that can be done in a group, such as creating proposals and commits.
305/// More information [here](https://messaginglayersecurity.rocks/mls-architecture/draft-ietf-mls-architecture.html#name-general-setting)
306#[derive(Debug)]
307pub struct MlsConversation {
308    pub(crate) id: ConversationId,
309    pub(crate) group: MlsGroup,
310    configuration: MlsConversationConfiguration,
311}
312
313impl MlsConversation {
314    /// Creates a new group/conversation
315    pub async fn create(
316        id: ConversationId,
317        provider: &MlsCryptoProvider,
318        database: &Database,
319        credential_ref: &CredentialRef,
320        configuration: MlsConversationConfiguration,
321    ) -> Result<Self> {
322        let credential = credential_ref
323            .load(database)
324            .await
325            .map_err(RecursiveError::mls_credential_ref("getting credential"))?;
326
327        let group = MlsGroup::new_with_group_id(
328            provider,
329            &credential.signature_key_pair,
330            &configuration.as_openmls_default_configuration()?,
331            openmls::prelude::GroupId::from_slice(id.as_ref()),
332            credential.to_mls_credential_with_key(),
333        )
334        .await
335        .map_err(MlsError::wrap("creating group with id"))?;
336
337        let mut conversation = Self {
338            id,
339            group,
340            configuration,
341        };
342
343        conversation.persist_group_when_changed(database, true).await?;
344
345        Ok(conversation)
346    }
347
348    /// Internal API: create a group from an existing conversation. For example by external commit
349    pub(crate) async fn from_mls_group(
350        group: MlsGroup,
351        configuration: MlsConversationConfiguration,
352        database: &Database,
353    ) -> Result<Self> {
354        let id = ConversationId::from(group.group_id().as_slice());
355
356        let mut conversation = Self {
357            id,
358            group,
359            configuration,
360        };
361
362        conversation.persist_group_when_changed(database, true).await?;
363
364        Ok(conversation)
365    }
366
367    /// Group/conversation id
368    pub fn id(&self) -> &ConversationId {
369        &self.id
370    }
371
372    pub(crate) fn group(&self) -> &MlsGroup {
373        &self.group
374    }
375
376    /// Get actual group members and subtract pending remove proposals
377    pub fn members_in_next_epoch(&self) -> Vec<ClientId> {
378        let pending_removals = self.pending_removals();
379        let existing_clients = self
380            .group
381            .members()
382            .filter_map(|kp| {
383                if !pending_removals.contains(&kp.index) {
384                    Some(kp.credential.identity().to_owned().into())
385                } else {
386                    trace!(client_index:% = kp.index; "Client is pending removal");
387                    None
388                }
389            })
390            .collect::<HashSet<_>>();
391        existing_clients.into_iter().collect()
392    }
393
394    /// Gather pending remove proposals
395    fn pending_removals(&self) -> Vec<LeafNodeIndex> {
396        self.group
397            .pending_proposals()
398            .filter_map(|proposal| match proposal.proposal() {
399                Proposal::Remove(remove) => Some(remove.removed()),
400                _ => None,
401            })
402            .collect::<Vec<_>>()
403    }
404
405    pub(crate) fn ciphersuite(&self) -> CipherSuite {
406        self.configuration.ciphersuite
407    }
408
409    fn extract_own_updated_node_from_proposals<'a>(
410        own_index: &LeafNodeIndex,
411        pending_proposals: impl Iterator<Item = &'a QueuedProposal>,
412    ) -> Option<&'a LeafNode> {
413        pending_proposals
414            .filter_map(|proposal| {
415                if let Sender::Member(index) = proposal.sender()
416                    && index == own_index
417                    && let Proposal::Update(update_proposal) = proposal.proposal()
418                {
419                    return Some(update_proposal.leaf_node());
420                }
421                None
422            })
423            .last()
424    }
425
426    async fn find_credential_for_leaf_node(
427        &self,
428        session: &Session<Database>,
429        leaf_node: &LeafNode,
430    ) -> Result<Arc<Credential>> {
431        let credential = session
432            .find_credential_by_public_key(leaf_node.signature_key())
433            .await
434            .map_err(RecursiveError::mls_client("finding current credential"))?;
435        Ok(credential)
436    }
437
438    pub(crate) async fn find_current_credential(&self, client: &Session<Database>) -> Result<Arc<Credential>> {
439        // if the group has pending proposals one of which is an own update proposal, we should take the credential from
440        // there.
441        let own_leaf = Self::extract_own_updated_node_from_proposals(
442            &self.group().own_leaf_index(),
443            self.group().pending_proposals(),
444        )
445        .or_else(|| self.group.own_leaf())
446        .ok_or(LeafError::InternalMlsError)?;
447        self.find_credential_for_leaf_node(client, own_leaf).await
448    }
449}
450
451#[cfg(test)]
452pub mod test_utils {
453    use openmls::prelude::SignaturePublicKey;
454
455    use super::*;
456
457    impl MlsConversation {
458        pub fn signature_keys(&self) -> impl Iterator<Item = SignaturePublicKey> + '_ {
459            self.group
460                .members()
461                .map(|m| m.signature_key)
462                .map(|mpk| SignaturePublicKey::from(mpk.as_slice()))
463        }
464
465        pub fn encryption_keys(&self) -> impl Iterator<Item = Vec<u8>> + '_ {
466            self.group.members().map(|m| m.encryption_key)
467        }
468
469        pub fn extensions(&self) -> &openmls::prelude::Extensions {
470            self.group.export_group_context().extensions()
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::test_utils::*;
479
480    #[apply(all_cred_cipher)]
481    pub async fn create_self_conversation_should_succeed(case: TestContext) {
482        let [alice] = case.sessions().await;
483        Box::pin(async move {
484            let conversation = case.create_conversation([&alice]).await;
485            assert_eq!(1, conversation.member_count().await);
486            let alice_can_send_message = conversation.guard().await.encrypt_message(b"me").await;
487            assert!(alice_can_send_message.is_ok());
488        })
489        .await;
490    }
491
492    #[apply(all_cred_cipher)]
493    pub async fn create_1_1_conversation_should_succeed(case: TestContext) {
494        let [alice, bob] = case.sessions().await;
495        Box::pin(async move {
496            let conversation = case.create_conversation([&alice, &bob]).await;
497            assert_eq!(2, conversation.member_count().await);
498            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
499        })
500        .await;
501    }
502
503    #[apply(all_cred_cipher)]
504    pub async fn create_many_people_conversation(case: TestContext) {
505        const SIZE_PLUS_1: usize = GROUP_SAMPLE_SIZE + 1;
506        let alice_and_friends = case.sessions::<SIZE_PLUS_1>().await;
507        Box::pin(async move {
508            let alice = &alice_and_friends[0];
509            let conversation = case.create_conversation([alice]).await;
510
511            let bob_and_friends = &alice_and_friends[1..];
512            let conversation = conversation.invite_notify(bob_and_friends).await;
513
514            assert_eq!(conversation.member_count().await, 1 + GROUP_SAMPLE_SIZE);
515            assert!(conversation.is_functional_and_contains(&alice_and_friends).await);
516        })
517        .await;
518    }
519
520    mod wire_identity_getters {
521        use super::Error;
522        use crate::{
523            ClientId, CredentialType, DeviceStatus, E2eiConversationState, mls::conversation::Conversation,
524            test_utils::*,
525        };
526
527        async fn all_identities_check<'a, C, const N: usize>(
528            conversation: &'a C,
529            user_ids: &[String; N],
530            expected_sizes: [usize; N],
531        ) where
532            C: Conversation<'a> + Sync,
533        {
534            let all_identities = conversation.get_user_identities(user_ids).await.unwrap();
535            assert_eq!(all_identities.len(), N);
536            for (expected_size, user_id) in expected_sizes.into_iter().zip(user_ids.iter()) {
537                let alice_identities = all_identities.get(user_id).unwrap();
538                assert_eq!(alice_identities.len(), expected_size);
539            }
540            // Not found
541            let not_found = conversation
542                .get_user_identities(&["aaaaaaaaaaaaa".to_string()])
543                .await
544                .unwrap();
545            assert!(not_found.is_empty());
546
547            // Invalid usage
548            let invalid = conversation.get_user_identities(&[]).await;
549            assert!(matches!(invalid.unwrap_err(), Error::CallerError(_)));
550        }
551
552        async fn check_identities_device_status<'a, C, const N: usize>(
553            conversation: &'a C,
554            client_ids: &[ClientId; N],
555            name_status: &[(impl ToString, DeviceStatus); N],
556        ) where
557            C: Conversation<'a> + Sync,
558        {
559            let mut identities = conversation.get_device_identities(client_ids).await.unwrap();
560
561            for (user_name, status) in name_status.iter() {
562                let client_identity = identities.remove(
563                    identities
564                        .iter()
565                        .position(|i| i.x509_identity.as_ref().unwrap().display_name == user_name.to_string())
566                        .unwrap(),
567                );
568                assert_eq!(client_identity.status, *status);
569            }
570            assert!(identities.is_empty());
571
572            assert_eq!(
573                conversation.e2ei_conversation_state().await.unwrap(),
574                E2eiConversationState::NotVerified
575            );
576        }
577
578        #[macro_rules_attribute::apply(smol_macros::test)]
579        async fn should_read_device_identities() {
580            let case = TestContext::default_x509();
581
582            let [alice_android, alice_ios] = case.sessions().await;
583            Box::pin(async move {
584                let conversation = case.create_conversation([&alice_android, &alice_ios]).await;
585
586                let (android_id, ios_id) = (alice_android.get_client_id().await, alice_ios.get_client_id().await);
587
588                let mut android_ids = conversation
589                    .guard()
590                    .await
591                    .get_device_identities(&[android_id.clone(), ios_id.clone()])
592                    .await
593                    .unwrap();
594                android_ids.sort_by(|a, b| a.client_id.cmp(&b.client_id));
595                assert_eq!(android_ids.len(), 2);
596                let mut ios_ids = conversation
597                    .guard_of(&alice_ios)
598                    .await
599                    .get_device_identities(&[android_id.clone(), ios_id.clone()])
600                    .await
601                    .unwrap();
602                ios_ids.sort_by(|a, b| a.client_id.cmp(&b.client_id));
603                assert_eq!(ios_ids.len(), 2);
604
605                assert_eq!(android_ids, ios_ids);
606
607                let android_identities = conversation
608                    .guard()
609                    .await
610                    .get_device_identities(&[android_id])
611                    .await
612                    .unwrap();
613                let android_id = android_identities.first().unwrap();
614                assert_eq!(
615                    android_id.client_id.as_bytes(),
616                    alice_android.transaction.client_id().await.unwrap().0.as_slice()
617                );
618
619                let ios_identities = conversation
620                    .guard()
621                    .await
622                    .get_device_identities(&[ios_id])
623                    .await
624                    .unwrap();
625                let ios_id = ios_identities.first().unwrap();
626                assert_eq!(
627                    ios_id.client_id.as_bytes(),
628                    alice_ios.transaction.client_id().await.unwrap().0.as_slice()
629                );
630
631                let empty_slice: &[ClientId] = &[];
632                let invalid = conversation.guard().await.get_device_identities(empty_slice).await;
633                assert!(matches!(invalid.unwrap_err(), Error::CallerError(_)));
634            })
635            .await
636        }
637
638        // TODO: ignore this test for now, until we rework the test suite & CRL handling (WPB-19580).
639        #[ignore]
640        #[macro_rules_attribute::apply(smol_macros::test)]
641        async fn should_read_revoked_device() {
642            let case = TestContext::default_x509();
643            let rupert_user_id = uuid::Uuid::new_v4();
644            let bob_user_id = uuid::Uuid::new_v4();
645            let alice_user_id = uuid::Uuid::new_v4();
646
647            let [rupert_client_id] = case.x509_client_ids_for_user(&rupert_user_id);
648            let [alice_client_id] = case.x509_client_ids_for_user(&alice_user_id);
649            let [bob_client_id] = case.x509_client_ids_for_user(&bob_user_id);
650
651            let sessions = case
652                .sessions_x509_with_client_ids_and_revocation(
653                    [alice_client_id.clone(), bob_client_id.clone(), rupert_client_id.clone()],
654                    &[rupert_user_id.to_string()],
655                )
656                .await;
657
658            Box::pin(async move {
659                let [alice, bob, rupert] = &sessions;
660                let conversation = case.create_conversation(&sessions).await;
661
662                let (alice_id, bob_id, rupert_id) = (
663                    alice.get_client_id().await,
664                    bob.get_client_id().await,
665                    rupert.get_client_id().await,
666                );
667
668                let client_ids = [alice_id, bob_id, rupert_id];
669                let name_status = [
670                    (alice_user_id, DeviceStatus::Valid),
671                    (bob_user_id, DeviceStatus::Valid),
672                    (rupert_user_id, DeviceStatus::Revoked),
673                ];
674
675                // Do it a multiple times to avoid WPB-6904 happening again
676                for _ in 0..2 {
677                    for session in sessions.iter() {
678                        let conversation = conversation.guard_of(session).await;
679                        check_identities_device_status(&conversation, &client_ids, &name_status).await;
680                    }
681                }
682            })
683            .await
684        }
685
686        #[macro_rules_attribute::apply(smol_macros::test)]
687        async fn should_not_fail_when_basic() {
688            let case = TestContext::default();
689
690            let [alice_android, alice_ios] = case.sessions().await;
691            Box::pin(async move {
692                let conversation = case.create_conversation([&alice_android, &alice_ios]).await;
693
694                let (android_id, ios_id) = (alice_android.get_client_id().await, alice_ios.get_client_id().await);
695
696                let mut android_ids = conversation
697                    .guard()
698                    .await
699                    .get_device_identities(&[android_id.clone(), ios_id.clone()])
700                    .await
701                    .unwrap();
702                android_ids.sort();
703
704                let mut ios_ids = conversation
705                    .guard_of(&alice_ios)
706                    .await
707                    .get_device_identities(&[android_id, ios_id])
708                    .await
709                    .unwrap();
710                ios_ids.sort();
711
712                assert_eq!(ios_ids.len(), 2);
713                assert_eq!(ios_ids, android_ids);
714
715                assert!(ios_ids.iter().all(|i| {
716                    matches!(i.credential_type, CredentialType::Basic)
717                        && matches!(i.status, DeviceStatus::Valid)
718                        && i.x509_identity.is_none()
719                        && !i.thumbprint.is_empty()
720                        && !i.client_id.is_empty()
721                }));
722            })
723            .await
724        }
725
726        #[macro_rules_attribute::apply(smol_macros::test)]
727        async fn should_read_users() {
728            let case = TestContext::default_x509();
729            let [alice_android, alice_ios] = case.x509_client_ids_for_user(&uuid::Uuid::new_v4());
730            let [bob_android] = case.x509_client_ids();
731
732            let sessions = case
733                .sessions_x509_with_client_ids([alice_android, alice_ios, bob_android])
734                .await;
735
736            Box::pin(async move {
737                let conversation = case.create_conversation(&sessions).await;
738
739                let nb_members = conversation.member_count().await;
740                assert_eq!(nb_members, 3);
741
742                let [alice_android, alice_ios, bob_android] = &sessions;
743                assert_eq!(alice_android.get_user_id().await, alice_ios.get_user_id().await);
744
745                // Finds both Alice's devices
746                let alice_user_id = alice_android.get_user_id().await;
747                let alice_identities = conversation
748                    .guard()
749                    .await
750                    .get_user_identities(std::slice::from_ref(&alice_user_id))
751                    .await
752                    .unwrap();
753                assert_eq!(alice_identities.len(), 1);
754                let identities = alice_identities.get(&alice_user_id).unwrap();
755                assert_eq!(identities.len(), 2);
756
757                // Finds Bob only device
758                let bob_user_id = bob_android.get_user_id().await;
759                let bob_identities = conversation
760                    .guard()
761                    .await
762                    .get_user_identities(std::slice::from_ref(&bob_user_id))
763                    .await
764                    .unwrap();
765                assert_eq!(bob_identities.len(), 1);
766                let identities = bob_identities.get(&bob_user_id).unwrap();
767                assert_eq!(identities.len(), 1);
768
769                let user_ids = [alice_user_id, bob_user_id];
770                let expected_sizes = [2, 1];
771
772                for session in &sessions {
773                    all_identities_check(&conversation.guard_of(session).await, &user_ids, expected_sizes).await;
774                }
775            })
776            .await
777        }
778    }
779
780    mod export_secret {
781        use openmls::prelude::ExportSecretError;
782
783        use super::*;
784        use crate::MlsErrorKind;
785
786        #[apply(all_cred_cipher)]
787        pub async fn can_export_secret_key(case: TestContext) {
788            let [alice] = case.sessions().await;
789            Box::pin(async move {
790                let conversation = case.create_conversation([&alice]).await;
791
792                let key_length = 128;
793                let result = conversation.guard().await.export_secret_key(key_length).await;
794                assert!(result.is_ok());
795                assert_eq!(result.unwrap().len(), key_length);
796            })
797            .await
798        }
799
800        #[apply(all_cred_cipher)]
801        pub async fn cannot_export_secret_key_invalid_length(case: TestContext) {
802            let [alice] = case.sessions().await;
803            Box::pin(async move {
804                let conversation = case.create_conversation([&alice]).await;
805
806                let result = conversation.guard().await.export_secret_key(usize::MAX).await;
807                let error = result.unwrap_err();
808                assert!(innermost_source_matches!(
809                    error,
810                    MlsErrorKind::MlsExportSecretError(ExportSecretError::KeyLengthTooLong)
811                ));
812            })
813            .await
814        }
815    }
816
817    mod get_client_ids {
818        use super::*;
819
820        #[apply(all_cred_cipher)]
821        pub async fn can_get_client_ids(case: TestContext) {
822            let [alice, bob] = case.sessions().await;
823            Box::pin(async move {
824                let conversation = case.create_conversation([&alice]).await;
825
826                assert_eq!(conversation.guard().await.get_client_ids().await.len(), 1);
827
828                let conversation = conversation.invite_notify([&bob]).await;
829
830                assert_eq!(conversation.guard().await.get_client_ids().await.len(), 2);
831            })
832            .await
833        }
834    }
835
836    mod external_sender {
837        use super::*;
838
839        #[apply(all_cred_cipher)]
840        pub async fn should_fetch_ext_sender(mut case: TestContext) {
841            let [alice, external_sender] = case.sessions().await;
842            Box::pin(async move {
843                use core_crypto_keystore::Sha256Hash;
844
845                let conversation = case
846                    .create_conversation_with_external_sender(&external_sender, [&alice])
847                    .await;
848
849                let alice_ext_sender = conversation.guard().await.get_external_sender().await.unwrap();
850                assert!(!alice_ext_sender.is_empty());
851                assert_eq!(
852                    Sha256Hash::hash_from(alice_ext_sender),
853                    external_sender.initial_credential.public_key_hash()
854                );
855            })
856            .await
857        }
858    }
859}