core_crypto/e2e_identity/
conversation_state.rs

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