core_crypto/e2e_identity/
conversation_state.rs

1use crate::{
2    MlsError, RecursiveError,
3    mls::credential::ext::CredentialExt,
4    prelude::{MlsCentral, MlsCredentialType},
5};
6
7use openmls_traits::OpenMlsCryptoProvider;
8use wire_e2e_identity::prelude::WireIdentityReader;
9
10use crate::context::CentralContext;
11use crate::prelude::MlsCiphersuite;
12use openmls::{
13    messages::group_info::VerifiableGroupInfo,
14    prelude::{Credential, Node},
15    treesync::RatchetTree,
16};
17
18use super::Result;
19
20/// Indicates the state of a Conversation regarding end-to-end identity.
21///
22/// Note: this does not check pending state (pending commit, pending proposals) so it does not
23/// consider members about to be added/removed
24#[derive(Debug, Clone, Copy, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
25#[repr(u8)]
26pub enum E2eiConversationState {
27    /// All clients have a valid E2EI certificate
28    Verified = 1,
29    /// Some clients are either still Basic or their certificate is expired
30    NotVerified,
31    /// All clients are still Basic. If all client have expired certificates, [E2eiConversationState::NotVerified] is returned.
32    NotEnabled,
33}
34
35impl CentralContext {
36    /// See [MlsCentral::e2ei_verify_group_state].
37    pub async fn e2ei_verify_group_state(&self, group_info: VerifiableGroupInfo) -> Result<E2eiConversationState> {
38        let mls_provider = self
39            .mls_provider()
40            .await
41            .map_err(RecursiveError::root("getting mls provider"))?;
42        let auth_service = mls_provider.authentication_service();
43        auth_service.refresh_time_of_interest().await;
44        let cs = group_info.ciphersuite().into();
45
46        let is_sender = true; // verify the ratchet tree as sender to turn on hardened verification
47        let Ok(rt) = group_info
48            .take_ratchet_tree(
49                &self
50                    .mls_provider()
51                    .await
52                    .map_err(RecursiveError::root("getting mls provider"))?,
53                is_sender,
54            )
55            .await
56        else {
57            return Ok(E2eiConversationState::NotVerified);
58        };
59
60        let credentials = rt.iter().filter_map(|n| match n {
61            Some(Node::LeafNode(ln)) => Some(ln.credential()),
62            _ => None,
63        });
64
65        let auth_service = auth_service.borrow().await;
66        Ok(compute_state(cs, credentials, MlsCredentialType::X509, auth_service.as_ref()).await)
67    }
68
69    /// See [MlsCentral::get_credential_in_use].
70    pub async fn get_credential_in_use(
71        &self,
72        group_info: VerifiableGroupInfo,
73        credential_type: MlsCredentialType,
74    ) -> Result<E2eiConversationState> {
75        let cs = group_info.ciphersuite().into();
76        // Not verifying the supplied the GroupInfo here could let attackers lure the clients about
77        // the e2ei state of a conversation and as a consequence degrade this conversation for all
78        // participants once joining it.
79        // This 👇 verifies the GroupInfo and the RatchetTree btw
80        let rt = group_info
81            .take_ratchet_tree(
82                &self
83                    .mls_provider()
84                    .await
85                    .map_err(RecursiveError::root("getting mls provider"))?,
86                false,
87            )
88            .await
89            .map_err(MlsError::wrap("taking ratchet tree"))?;
90        let mls_provider = self
91            .mls_provider()
92            .await
93            .map_err(RecursiveError::root("getting mls provider"))?;
94        let auth_service = mls_provider.authentication_service().borrow().await;
95        get_credential_in_use_in_ratchet_tree(cs, rt, credential_type, auth_service.as_ref()).await
96    }
97}
98
99impl MlsCentral {
100    /// Verifies a Group state before joining it
101    pub async fn e2ei_verify_group_state(&self, group_info: VerifiableGroupInfo) -> Result<E2eiConversationState> {
102        self.mls_backend
103            .authentication_service()
104            .refresh_time_of_interest()
105            .await;
106
107        let cs = group_info.ciphersuite().into();
108
109        let is_sender = true; // verify the ratchet tree as sender to turn on hardened verification
110        let Ok(rt) = group_info.take_ratchet_tree(&self.mls_backend, is_sender).await else {
111            return Ok(E2eiConversationState::NotVerified);
112        };
113
114        let credentials = rt.iter().filter_map(|n| match n {
115            Some(Node::LeafNode(ln)) => Some(ln.credential()),
116            _ => None,
117        });
118
119        Ok(compute_state(
120            cs,
121            credentials,
122            MlsCredentialType::X509,
123            self.mls_backend.authentication_service().borrow().await.as_ref(),
124        )
125        .await)
126    }
127
128    /// Gets the e2ei conversation state from a `GroupInfo`. Useful to check if the group has e2ei
129    /// turned on or not before joining it.
130    pub async fn get_credential_in_use(
131        &self,
132        group_info: VerifiableGroupInfo,
133        credential_type: MlsCredentialType,
134    ) -> Result<E2eiConversationState> {
135        let cs = group_info.ciphersuite().into();
136        // Not verifying the supplied the GroupInfo here could let attackers lure the clients about
137        // the e2ei state of a conversation and as a consequence degrade this conversation for all
138        // participants once joining it.
139        // This 👇 verifies the GroupInfo and the RatchetTree btw
140        let rt = group_info
141            .take_ratchet_tree(&self.mls_backend, false)
142            .await
143            .map_err(MlsError::wrap("taking ratchet tree"))?;
144        get_credential_in_use_in_ratchet_tree(
145            cs,
146            rt,
147            credential_type,
148            self.mls_backend.authentication_service().borrow().await.as_ref(),
149        )
150        .await
151    }
152}
153
154async fn get_credential_in_use_in_ratchet_tree(
155    ciphersuite: MlsCiphersuite,
156    ratchet_tree: RatchetTree,
157    credential_type: MlsCredentialType,
158    env: Option<&wire_e2e_identity::prelude::x509::revocation::PkiEnvironment>,
159) -> Result<E2eiConversationState> {
160    let credentials = ratchet_tree.iter().filter_map(|n| match n {
161        Some(Node::LeafNode(ln)) => Some(ln.credential()),
162        _ => None,
163    });
164    Ok(compute_state(ciphersuite, credentials, credential_type, env).await)
165}
166
167/// _credential_type will be used in the future to get the usage of VC Credentials, even Basics one.
168/// Right now though, we do not need anything other than X509 so let's keep things simple.
169pub(crate) async fn compute_state<'a>(
170    ciphersuite: MlsCiphersuite,
171    credentials: impl Iterator<Item = &'a Credential>,
172    _credential_type: MlsCredentialType,
173    env: Option<&wire_e2e_identity::prelude::x509::revocation::PkiEnvironment>,
174) -> E2eiConversationState {
175    let mut is_e2ei = false;
176    let mut state = E2eiConversationState::Verified;
177
178    for credential in credentials {
179        let Ok(Some(cert)) = credential.parse_leaf_cert() else {
180            state = E2eiConversationState::NotVerified;
181            if is_e2ei {
182                break;
183            }
184            continue;
185        };
186
187        is_e2ei = true;
188
189        let invalid_identity = cert.extract_identity(env, ciphersuite.e2ei_hash_alg()).is_err();
190
191        use openmls_x509_credential::X509Ext as _;
192        let is_time_valid = cert.is_time_valid().unwrap_or(false);
193        let is_time_invalid = !is_time_valid;
194        let is_revoked_or_invalid = env
195            .map(|e| e.validate_cert_and_revocation(&cert).is_err())
196            .unwrap_or(false);
197
198        let is_invalid = invalid_identity || is_time_invalid || is_revoked_or_invalid;
199        if is_invalid {
200            state = E2eiConversationState::NotVerified;
201            break;
202        }
203    }
204
205    if is_e2ei {
206        state
207    } else {
208        E2eiConversationState::NotEnabled
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use crate::e2e_identity::rotate::tests::all::failsafe_ctx;
215    use wasm_bindgen_test::*;
216
217    use super::*;
218    use crate::mls::conversation::Conversation as _;
219    use crate::{
220        prelude::{CertificateBundle, Client, MlsCredentialType},
221        test_utils::*,
222    };
223
224    wasm_bindgen_test_configure!(run_in_browser);
225
226    // testing the case where both Bob & Alice have the same Credential type
227    #[apply(all_cred_cipher)]
228    #[wasm_bindgen_test]
229    async fn uniform_conversation_should_be_not_verified_when_basic(case: TestCase) {
230        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
231            Box::pin(async move {
232                let id = conversation_id();
233
234                // That way the conversation creator (Alice) will have the same credential type as Bob
235                let creator_ct = case.credential_type;
236                alice_central
237                    .context
238                    .new_conversation(&id, creator_ct, case.cfg.clone())
239                    .await
240                    .unwrap();
241                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
242
243                match case.credential_type {
244                    MlsCredentialType::Basic => {
245                        let alice_state = alice_central
246                            .context
247                            .conversation(&id)
248                            .await
249                            .unwrap()
250                            .e2ei_conversation_state()
251                            .await
252                            .unwrap();
253                        let bob_state = bob_central
254                            .context
255                            .conversation(&id)
256                            .await
257                            .unwrap()
258                            .e2ei_conversation_state()
259                            .await
260                            .unwrap();
261                        assert_eq!(alice_state, E2eiConversationState::NotEnabled);
262                        assert_eq!(bob_state, E2eiConversationState::NotEnabled);
263
264                        let gi = alice_central.get_group_info(&id).await;
265                        let state = alice_central
266                            .context
267                            .get_credential_in_use(gi, MlsCredentialType::X509)
268                            .await
269                            .unwrap();
270                        assert_eq!(state, E2eiConversationState::NotEnabled);
271                    }
272                    MlsCredentialType::X509 => {
273                        let alice_state = alice_central
274                            .context
275                            .conversation(&id)
276                            .await
277                            .unwrap()
278                            .e2ei_conversation_state()
279                            .await
280                            .unwrap();
281                        let bob_state = bob_central
282                            .context
283                            .conversation(&id)
284                            .await
285                            .unwrap()
286                            .e2ei_conversation_state()
287                            .await
288                            .unwrap();
289                        assert_eq!(alice_state, E2eiConversationState::Verified);
290                        assert_eq!(bob_state, E2eiConversationState::Verified);
291
292                        let gi = alice_central.get_group_info(&id).await;
293                        let state = alice_central
294                            .context
295                            .get_credential_in_use(gi, MlsCredentialType::X509)
296                            .await
297                            .unwrap();
298                        assert_eq!(state, E2eiConversationState::Verified);
299                    }
300                }
301            })
302        })
303        .await
304    }
305
306    // testing the case where Bob & Alice have different Credential type
307    #[apply(all_cred_cipher)]
308    #[wasm_bindgen_test]
309    async fn heterogeneous_conversation_should_be_not_verified(case: TestCase) {
310        run_test_with_client_ids(
311            case.clone(),
312            ["alice", "bob"],
313            move |[mut alice_central, mut bob_central]| {
314                Box::pin(async move {
315                    let id = conversation_id();
316                    let x509_test_chain_arc =
317                        failsafe_ctx(&mut [&mut alice_central, &mut bob_central], case.signature_scheme()).await;
318
319                    let x509_test_chain = x509_test_chain_arc.as_ref().as_ref().unwrap();
320
321                    // That way the conversation creator (Alice) will have a different credential type than Bob
322                    let alice_client = alice_central.context.mls_client().await.unwrap();
323                    let alice_provider = alice_central.context.mls_provider().await.unwrap();
324                    let creator_ct = match case.credential_type {
325                        MlsCredentialType::Basic => {
326                            let intermediate_ca = x509_test_chain.find_local_intermediate_ca();
327                            let cert_bundle =
328                                CertificateBundle::rand(&alice_client.id().await.unwrap(), intermediate_ca);
329                            alice_client
330                                .init_x509_credential_bundle_if_missing(
331                                    &alice_provider,
332                                    case.signature_scheme(),
333                                    cert_bundle,
334                                )
335                                .await
336                                .unwrap();
337                            MlsCredentialType::X509
338                        }
339                        MlsCredentialType::X509 => {
340                            alice_client
341                                .init_basic_credential_bundle_if_missing(&alice_provider, case.signature_scheme())
342                                .await
343                                .unwrap();
344                            MlsCredentialType::Basic
345                        }
346                    };
347
348                    alice_central
349                        .context
350                        .new_conversation(&id, creator_ct, case.cfg.clone())
351                        .await
352                        .unwrap();
353                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
354
355                    // since in that case both have a different credential type the conversation is always not verified
356                    let alice_state = alice_central
357                        .context
358                        .conversation(&id)
359                        .await
360                        .unwrap()
361                        .e2ei_conversation_state()
362                        .await
363                        .unwrap();
364                    let bob_state = bob_central
365                        .context
366                        .conversation(&id)
367                        .await
368                        .unwrap()
369                        .e2ei_conversation_state()
370                        .await
371                        .unwrap();
372                    assert_eq!(alice_state, E2eiConversationState::NotVerified);
373                    assert_eq!(bob_state, E2eiConversationState::NotVerified);
374
375                    let gi = alice_central.get_group_info(&id).await;
376                    let state = alice_central
377                        .context
378                        .get_credential_in_use(gi, MlsCredentialType::X509)
379                        .await
380                        .unwrap();
381                    assert_eq!(state, E2eiConversationState::NotVerified);
382                })
383            },
384        )
385        .await
386    }
387
388    #[apply(all_cred_cipher)]
389    #[wasm_bindgen_test]
390    async fn should_be_not_verified_when_one_expired(case: TestCase) {
391        if !case.is_x509() {
392            return;
393        }
394        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
395            Box::pin(async move {
396                let id = conversation_id();
397
398                alice_central
399                    .context
400                    .new_conversation(&id, case.credential_type, case.cfg.clone())
401                    .await
402                    .unwrap();
403                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
404
405                let expiration_time = core::time::Duration::from_secs(14);
406                let start = web_time::Instant::now();
407
408                let intermediate_ca = alice_central
409                    .x509_test_chain
410                    .as_ref()
411                    .as_ref()
412                    .expect("No x509 test chain")
413                    .find_local_intermediate_ca();
414                let cert = CertificateBundle::new_with_default_values(intermediate_ca, Some(expiration_time));
415                let cb = Client::new_x509_credential_bundle(cert.clone()).unwrap();
416                alice_central
417                    .context
418                    .conversation(&id)
419                    .await
420                    .unwrap()
421                    .e2ei_rotate(Some(&cb))
422                    .await
423                    .unwrap();
424                let commit = alice_central.mls_transport.latest_commit().await;
425                bob_central
426                    .context
427                    .conversation(&id)
428                    .await
429                    .unwrap()
430                    .decrypt_message(commit.to_bytes().unwrap())
431                    .await
432                    .unwrap();
433
434                let alice_client = alice_central.context.mls_client().await.unwrap();
435                let alice_provider = alice_central.context.mls_provider().await.unwrap();
436                // Needed because 'e2ei_rotate' does not do it directly and it's required for 'get_group_info'
437                alice_client
438                    .save_new_x509_credential_bundle(&alice_provider.keystore(), case.signature_scheme(), cert)
439                    .await
440                    .unwrap();
441
442                // Need to fetch it before it becomes invalid & expires
443                let gi = alice_central.get_group_info(&id).await;
444
445                let elapsed = start.elapsed();
446                // Give time to the certificate to expire
447                if expiration_time > elapsed {
448                    async_std::task::sleep(expiration_time - elapsed + core::time::Duration::from_secs(1)).await;
449                }
450
451                let alice_state = alice_central
452                    .context
453                    .conversation(&id)
454                    .await
455                    .unwrap()
456                    .e2ei_conversation_state()
457                    .await
458                    .unwrap();
459                let bob_state = bob_central
460                    .context
461                    .conversation(&id)
462                    .await
463                    .unwrap()
464                    .e2ei_conversation_state()
465                    .await
466                    .unwrap();
467                assert_eq!(alice_state, E2eiConversationState::NotVerified);
468                assert_eq!(bob_state, E2eiConversationState::NotVerified);
469
470                let state = alice_central
471                    .context
472                    .get_credential_in_use(gi, MlsCredentialType::X509)
473                    .await
474                    .unwrap();
475                assert_eq!(state, E2eiConversationState::NotVerified);
476            })
477        })
478        .await
479    }
480
481    #[apply(all_cred_cipher)]
482    #[wasm_bindgen_test]
483    async fn should_be_not_verified_when_all_expired(case: TestCase) {
484        if case.is_x509() {
485            run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
486                Box::pin(async move {
487                    let id = conversation_id();
488
489                    alice_central
490                        .context
491                        .new_conversation(&id, case.credential_type, case.cfg.clone())
492                        .await
493                        .unwrap();
494
495                    let expiration_time = core::time::Duration::from_secs(14);
496                    let start = web_time::Instant::now();
497                    let alice_test_chain = alice_central.x509_test_chain.as_ref().as_ref().unwrap();
498
499                    let alice_intermediate_ca = alice_test_chain.find_local_intermediate_ca();
500                    let mut alice_cert = alice_test_chain
501                        .actors
502                        .iter()
503                        .find(|actor| actor.name == "alice")
504                        .unwrap()
505                        .clone();
506                    alice_intermediate_ca.update_end_identity(&mut alice_cert.certificate, Some(expiration_time));
507
508                    let cert_bundle =
509                        CertificateBundle::from_certificate_and_issuer(&alice_cert.certificate, alice_intermediate_ca);
510                    let cb = Client::new_x509_credential_bundle(cert_bundle.clone()).unwrap();
511                    alice_central
512                        .context
513                        .conversation(&id)
514                        .await
515                        .unwrap()
516                        .e2ei_rotate(Some(&cb))
517                        .await
518                        .unwrap();
519
520                    let alice_client = alice_central.client().await;
521                    let alice_provider = alice_central.context.mls_provider().await.unwrap();
522
523                    // Needed because 'e2ei_rotate' does not do it directly and it's required for 'get_group_info'
524                    alice_client
525                        .save_new_x509_credential_bundle(
526                            &alice_provider.keystore(),
527                            case.signature_scheme(),
528                            cert_bundle,
529                        )
530                        .await
531                        .unwrap();
532
533                    let elapsed = start.elapsed();
534                    // Give time to the certificate to expire
535                    if expiration_time > elapsed {
536                        async_std::task::sleep(expiration_time - elapsed + core::time::Duration::from_secs(1)).await;
537                    }
538
539                    let alice_state = alice_central
540                        .context
541                        .conversation(&id)
542                        .await
543                        .unwrap()
544                        .e2ei_conversation_state()
545                        .await
546                        .unwrap();
547                    assert_eq!(alice_state, E2eiConversationState::NotVerified);
548
549                    // Need to fetch it before it becomes invalid & expires
550                    let gi = alice_central.get_group_info(&id).await;
551
552                    let state = alice_central
553                        .context
554                        .get_credential_in_use(gi, MlsCredentialType::X509)
555                        .await
556                        .unwrap();
557                    assert_eq!(state, E2eiConversationState::NotVerified);
558                })
559            })
560            .await
561        }
562    }
563}