core_crypto/mls/credential/
mod.rs

1use openmls::prelude::{Credential, CredentialWithKey};
2use openmls_basic_credential::SignatureKeyPair;
3use std::cmp::Ordering;
4use std::hash::{Hash, Hasher};
5
6pub(crate) mod crl;
7mod error;
8pub(crate) mod ext;
9pub(crate) mod typ;
10pub(crate) mod x509;
11
12pub(crate) use error::{Error, Result};
13
14#[derive(Debug, serde::Serialize, serde::Deserialize)]
15pub struct CredentialBundle {
16    pub(crate) credential: Credential,
17    pub(crate) signature_key: SignatureKeyPair,
18    pub(crate) created_at: u64,
19}
20
21impl CredentialBundle {
22    pub fn credential(&self) -> &Credential {
23        &self.credential
24    }
25
26    pub(crate) fn signature_key(&self) -> &SignatureKeyPair {
27        &self.signature_key
28    }
29
30    pub fn to_mls_credential_with_key(&self) -> CredentialWithKey {
31        CredentialWithKey {
32            credential: self.credential.clone(),
33            signature_key: self.signature_key.to_public_vec().into(),
34        }
35    }
36}
37
38impl From<CredentialBundle> for CredentialWithKey {
39    fn from(cb: CredentialBundle) -> Self {
40        Self {
41            credential: cb.credential,
42            signature_key: cb.signature_key.public().into(),
43        }
44    }
45}
46
47impl Clone for CredentialBundle {
48    fn clone(&self) -> Self {
49        Self {
50            credential: self.credential.clone(),
51            signature_key: SignatureKeyPair::from_raw(
52                self.signature_key.signature_scheme(),
53                self.signature_key.private().to_vec(),
54                self.signature_key.to_public_vec(),
55            ),
56            created_at: self.created_at,
57        }
58    }
59}
60
61impl Eq for CredentialBundle {}
62impl PartialEq for CredentialBundle {
63    fn eq(&self, other: &Self) -> bool {
64        self.credential.eq(&other.credential)
65            && self.created_at.eq(&other.created_at)
66            && self
67                .signature_key
68                .signature_scheme()
69                .eq(&other.signature_key.signature_scheme())
70            && self.signature_key.public().eq(other.signature_key.public())
71    }
72}
73
74impl Hash for CredentialBundle {
75    fn hash<H: Hasher>(&self, state: &mut H) {
76        self.created_at.hash(state);
77        self.signature_key.signature_scheme().hash(state);
78        self.signature_key.public().hash(state);
79        self.credential().identity().hash(state);
80        match self.credential().mls_credential() {
81            openmls::prelude::MlsCredentialType::X509(cert) => {
82                cert.certificates.hash(state);
83            }
84            openmls::prelude::MlsCredentialType::Basic(_) => {}
85        };
86    }
87}
88
89impl Ord for CredentialBundle {
90    fn cmp(&self, other: &Self) -> Ordering {
91        self.created_at.cmp(&other.created_at)
92    }
93}
94
95impl PartialOrd for CredentialBundle {
96    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
97        Some(self.cmp(other))
98    }
99}
100
101// TODO: ensure certificate signature must match the group's ciphersuite ; fails otherwise. Tracking issue: WPB-9632
102// Requires more than 1 ciphersuite supported at the moment.
103#[cfg(test)]
104mod tests {
105    use mls_crypto_provider::PkiKeypair;
106    use std::collections::HashMap;
107    use wasm_bindgen_test::*;
108
109    use super::x509::CertificateBundle;
110    use super::*;
111    use crate::mls::conversation::Conversation as _;
112    use crate::{
113        mls::credential::x509::CertificatePrivateKey,
114        prelude::{ClientIdentifier, E2eiConversationState, MlsCredentialType},
115        test_utils::{
116            x509::{CertificateParams, X509TestChain},
117            *,
118        },
119    };
120
121    wasm_bindgen_test_configure!(run_in_browser);
122
123    #[apply(all_cred_cipher)]
124    #[wasm_bindgen_test]
125    async fn basic_clients_can_send_messages(case: TestContext) {
126        if !case.is_basic() {
127            return;
128        }
129        let [alice, bob] = case.sessions_basic().await;
130        let conversation = case.create_conversation([&alice, &bob]).await;
131        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
132    }
133
134    #[apply(all_cred_cipher)]
135    #[wasm_bindgen_test]
136    async fn certificate_clients_can_send_messages(case: TestContext) {
137        if !case.is_x509() {
138            return;
139        }
140        let [alice, bob] = case.sessions_x509().await;
141        let conversation = case.create_conversation([&alice, &bob]).await;
142        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
143    }
144
145    #[apply(all_cred_cipher)]
146    #[wasm_bindgen_test]
147    async fn heterogeneous_clients_can_send_messages(case: TestContext) {
148        // check that both credentials can initiate/join a group
149        let ([x509_session], [basic_session]) = case.sessions_mixed_credential_types().await;
150        // That way the conversation creator (Alice) will have a different credential type than Bob
151        let (alice, bob, alice_credential_type) = match case.credential_type {
152            MlsCredentialType::Basic => (x509_session, basic_session, MlsCredentialType::X509),
153            MlsCredentialType::X509 => (basic_session, x509_session, MlsCredentialType::Basic),
154        };
155
156        let conversation = case
157            .create_heterogeneous_conversation(alice_credential_type, case.credential_type, [&alice, &bob])
158            .await;
159        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
160    }
161
162    #[apply(all_cred_cipher)]
163    #[wasm_bindgen_test]
164    async fn should_fail_when_certificate_chain_is_empty(case: TestContext) {
165        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
166
167        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
168
169        let mut certs = CertificateBundle::rand(&"alice".into(), x509_intermediate);
170        certs.certificate_chain = vec![];
171        let alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), certs)]));
172
173        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
174            .await
175            .unwrap_err();
176        assert!(innermost_source_matches!(err, Error::InvalidIdentity));
177    }
178
179    #[apply(all_cred_cipher)]
180    #[wasm_bindgen_test]
181    async fn should_fail_when_certificate_chain_has_a_single_self_signed(case: TestContext) {
182        use crate::MlsErrorKind;
183
184        if !case.is_x509() {
185            return;
186        }
187        let mut x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
188
189        let (_alice_identifier, alice_cert) = x509_test_chain.issue_simple_certificate_bundle("alice", None);
190
191        let new_cert = alice_cert
192            .pki_keypair
193            .re_sign(&alice_cert.certificate, &alice_cert.certificate, None)
194            .unwrap();
195        let mut alice_cert = alice_cert.clone();
196        alice_cert.certificate = new_cert;
197        let cb = CertificateBundle::from_self_signed_certificate(&alice_cert);
198        let alice_identifier = ClientIdentifier::X509([(case.signature_scheme(), cb)].into());
199
200        let alice = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
201            .await
202            .unwrap();
203        let [bob] = case.sessions_x509().await;
204        let bob_key_package = bob.rand_key_package(&case).await;
205        let conversation = case.create_conversation([&alice]).await;
206        let err = conversation
207            .guard()
208            .await
209            .add_members([bob_key_package].into())
210            .await
211            .unwrap_err();
212        assert!(innermost_source_matches!(err, MlsErrorKind::MlsAddMembersError(_)));
213    }
214
215    #[apply(all_cred_cipher)]
216    #[wasm_bindgen_test]
217    async fn should_fail_when_signature_key_doesnt_match_certificate_public_key(case: TestContext) {
218        if !case.is_x509() {
219            return;
220        }
221        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
222        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
223
224        let certs = CertificateBundle::rand(&"alice".into(), x509_intermediate);
225        let new_pki_kp = PkiKeypair::rand_unchecked(case.signature_scheme());
226
227        let eve_key = CertificatePrivateKey {
228            value: new_pki_kp.signing_key_bytes(),
229            signature_scheme: case.ciphersuite().signature_algorithm(),
230        };
231        let cb = CertificateBundle {
232            certificate_chain: certs.certificate_chain,
233            private_key: eve_key,
234        };
235        let alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), cb)]));
236
237        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
238            .await
239            .unwrap_err();
240        assert!(innermost_source_matches!(
241            err,
242            crate::MlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::MismatchKeypair),
243        ));
244    }
245
246    #[apply(all_cred_cipher)]
247    #[wasm_bindgen_test]
248    async fn should_not_fail_but_degrade_when_certificate_expired(case: TestContext) {
249        if !case.is_x509() {
250            return;
251        }
252        Box::pin(async move {
253            let mut x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
254
255            let expiration_time = core::time::Duration::from_secs(14);
256            let start = web_time::Instant::now();
257
258            let (alice_identifier, _) = x509_test_chain.issue_simple_certificate_bundle("alice", None);
259            let (bob_identifier, _) = x509_test_chain.issue_simple_certificate_bundle("bob", Some(expiration_time));
260            let alice = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
261                .await
262                .unwrap();
263            let bob = SessionContext::new_with_identifier(&case, bob_identifier, Some(&x509_test_chain))
264                .await
265                .unwrap();
266
267            let conversation = case.create_conversation([&alice, &bob]).await;
268            // this should work since the certificate is not yet expired
269            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
270
271            assert_eq!(
272                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
273                E2eiConversationState::Verified
274            );
275
276            let elapsed = start.elapsed();
277            // Give time to the certificate to expire
278            if expiration_time > elapsed {
279                async_std::task::sleep(expiration_time - elapsed + core::time::Duration::from_secs(2)).await;
280            }
281
282            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
283            assert_eq!(
284                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
285                E2eiConversationState::NotVerified
286            );
287        })
288        .await;
289    }
290
291    #[apply(all_cred_cipher)]
292    #[wasm_bindgen_test]
293    async fn should_not_fail_but_degrade_when_basic_joins(case: TestContext) {
294        if !case.is_x509() {
295            return;
296        }
297        Box::pin(async {
298            let ([alice, bob], [charlie]) = case.sessions_mixed_credential_types().await;
299            let conversation = case.create_conversation([&alice, &bob]).await;
300
301            // this should work since the certificate is not yet expired
302            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
303            assert_eq!(
304                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
305                E2eiConversationState::Verified
306            );
307            assert_eq!(
308                conversation
309                    .guard_of(&bob)
310                    .await
311                    .e2ei_conversation_state()
312                    .await
313                    .unwrap(),
314                E2eiConversationState::Verified
315            );
316
317            // Charlie is a basic client that tries to join (i.e. emulates guest links in Wire)
318            let conversation = conversation
319                .invite_with_credential_type_notify(MlsCredentialType::Basic, [&charlie])
320                .await;
321
322            assert_eq!(
323                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
324                E2eiConversationState::NotVerified
325            );
326            assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
327            assert_eq!(
328                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
329                E2eiConversationState::NotVerified
330            );
331        })
332        .await;
333    }
334
335    #[apply(all_cred_cipher)]
336    #[wasm_bindgen_test]
337    async fn should_fail_when_certificate_not_valid_yet(case: TestContext) {
338        use crate::MlsErrorKind;
339
340        if !case.is_x509() {
341            return;
342        }
343        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
344
345        let tomorrow = now_std() + core::time::Duration::from_secs(3600 * 24);
346        let local_ca = x509_test_chain.find_local_intermediate_ca();
347        let alice_cert = {
348            let name = "alice";
349            let common_name = format!("{name} Smith");
350            let handle = format!("{}_wire", name.to_lowercase());
351            let client_id: String = crate::e2e_identity::id::QualifiedE2eiClientId::generate_with_domain("wire.com")
352                .try_into()
353                .unwrap();
354            local_ca.create_and_sign_end_identity(CertificateParams {
355                common_name: Some(common_name.clone()),
356                handle: Some(handle.clone()),
357                client_id: Some(client_id.clone()),
358                validity_start: Some(tomorrow),
359                ..Default::default()
360            })
361        };
362        let cb = CertificateBundle::from_certificate_and_issuer(&alice_cert, local_ca);
363        let alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), cb)]));
364        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
365            .await
366            .unwrap_err();
367
368        assert!(innermost_source_matches!(
369            err,
370            MlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::ExpiredCertificate),
371        ))
372    }
373
374    /// In order to be WASM-compatible
375    // pub fn now() -> wire_e2e_identity::prelude::OffsetDateTime {
376    //     let now_since_epoch = now_std().as_secs() as i64;
377    //     wire_e2e_identity::prelude::OffsetDateTime::from_unix_timestamp(now_since_epoch).unwrap()
378    // }
379    pub(crate) fn now_std() -> std::time::Duration {
380        let now = web_time::SystemTime::now();
381        now.duration_since(web_time::UNIX_EPOCH).unwrap()
382    }
383}