Skip to main content

core_crypto/mls/credential/
mod.rs

1//! This module focuses on [`Credential`]s: cryptographic assertions of identity.
2//!
3//! Credentials can be basic, or based on an x509 certificate chain.
4
5pub(crate) mod credential_ref;
6pub(crate) mod credential_type;
7pub(crate) mod crl;
8mod error;
9mod export_pem;
10pub(crate) mod ext;
11mod persistence;
12pub(crate) mod x509;
13
14use std::sync::Arc;
15
16use core_crypto_keystore::entities::StoredCredential;
17use openmls::prelude::{Credential as MlsCredential, CredentialWithKey, KeyPackage, SignatureScheme};
18use openmls_basic_credential::SignatureKeyPair;
19use openmls_traits::crypto::OpenMlsCrypto;
20use tls_codec::Deserialize as _;
21
22pub(crate) use self::error::Result;
23pub use self::{
24    credential_ref::{CredentialRef, FindFilters, FindFiltersBuilder},
25    credential_type::CredentialType,
26    error::Error,
27};
28use crate::{CipherSuite, ClientId, ClientIdRef, OpenMlsError, RecursiveError, TlsCodecError, mls_provider::CRYPTO};
29
30/// A cryptographic credential.
31///
32/// This is tied to a particular client via either its client id or certificate bundle,
33/// depending on its credential type, but is independent of any client instance or storage.
34///
35/// To attach to a particular client instance and store, see
36/// [`TransactionContext::add_credential`][crate::transaction_context::TransactionContext::add_credential].
37#[derive(core_crypto_macros::Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct Credential {
39    /// Ciphersuite used by this credential
40    pub(crate) cipher_suite: CipherSuite,
41    /// Credential type
42    pub(crate) credential_type: CredentialType,
43    /// MLS internal credential. Stores the MLS credential
44    pub(crate) mls_credential: MlsCredential,
45    /// Public and private keys, and the signature scheme.
46    #[sensitive]
47    pub(crate) signature_key_pair: SignatureKeyPair,
48    /// Earliest valid time of creation for this credential.
49    ///
50    /// This is represented as seconds after the unix epoch.
51    ///
52    /// Only meaningful for X509, where it is the "valid_from" claim of the leaf credential.
53    /// For basic credentials, this is always 0.
54    pub(crate) earliest_validity: u64,
55}
56
57impl TryFrom<Arc<StoredCredential>> for Credential {
58    type Error = Error;
59
60    fn try_from(stored_credential: Arc<StoredCredential>) -> Result<Credential> {
61        let mls_credential = MlsCredential::tls_deserialize(&mut stored_credential.credential.as_slice())
62            .map_err(TlsCodecError::deserialize("mls credential"))?;
63        let cipher_suite = CipherSuite::try_from(stored_credential.ciphersuite)?;
64        let signature_key_pair = openmls_basic_credential::SignatureKeyPair::from_raw(
65            cipher_suite.signature_algorithm(),
66            stored_credential.private_key.to_owned(),
67            stored_credential.public_key.to_owned(),
68        );
69        let credential_type = mls_credential
70            .credential_type()
71            .try_into()
72            .map_err(RecursiveError::context("loading credential from db"))?;
73        let earliest_validity = stored_credential.created_at;
74        Ok(Credential {
75            cipher_suite,
76            signature_key_pair,
77            credential_type,
78            mls_credential,
79            earliest_validity,
80        })
81    }
82}
83
84impl Credential {
85    /// Generate a basic credential.
86    ///
87    /// The result is independent of any client instance and the database; it lives in memory only.
88    ///
89    /// The earliest validity of this credential is always 0. It will be updated once the credential is added to a
90    /// session.
91    pub fn basic(cipher_suite: CipherSuite, client_id: ClientId) -> Result<Self> {
92        let signature_scheme = cipher_suite.signature_algorithm();
93        let (private_key, public_key) = CRYPTO
94            .signature_key_gen(signature_scheme)
95            .map_err(OpenMlsError::wrap("generating signature key"))?;
96        let signature_key_pair = SignatureKeyPair::from_raw(signature_scheme, private_key, public_key);
97
98        Ok(Self {
99            cipher_suite,
100            credential_type: CredentialType::Basic,
101            mls_credential: MlsCredential::new_basic(client_id.into_inner()),
102            signature_key_pair,
103            earliest_validity: 0,
104        })
105    }
106
107    /// Recover the public half of the credential which produced a key package.
108    ///
109    /// The result carries no private signature key, so it can be persisted and it identifies its
110    /// client, but it can never sign. That is all a history client ever has: the private key of its
111    /// credential is discarded as soon as the history secret is generated, which is what makes
112    /// such a client incapable of producing messages of its own.
113    pub(crate) fn public_only(key_package: &KeyPackage) -> Result<Self> {
114        let cipher_suite = CipherSuite::from(key_package.ciphersuite());
115        let leaf_node = key_package.leaf_node();
116        let mls_credential = leaf_node.credential().clone();
117        let credential_type = mls_credential.credential_type().try_into()?;
118        let signature_key_pair = SignatureKeyPair::from_raw(
119            cipher_suite.signature_algorithm(),
120            Vec::new(),
121            leaf_node.signature_key().as_slice().to_vec(),
122        );
123
124        Ok(Self {
125            cipher_suite,
126            credential_type,
127            mls_credential,
128            signature_key_pair,
129            earliest_validity: 0,
130        })
131    }
132
133    /// Get the Openmls Credential type.
134    ///
135    /// This stores the credential type (basic/x509).
136    pub fn mls_credential(&self) -> &MlsCredential {
137        &self.mls_credential
138    }
139
140    /// Get the credential type
141    pub fn credential_type(&self) -> CredentialType {
142        self.credential_type
143    }
144
145    /// Get a reference to the `SignatureKeyPair`.
146    pub(crate) fn signature_key(&self) -> &SignatureKeyPair {
147        &self.signature_key_pair
148    }
149
150    /// The signature key bytes.
151    // TODO temporary. Remove when https://wearezeta.atlassian.net/wiki/x/RABtrQ is resolved.
152    pub fn signature_key_bytes(&self) -> &[u8] {
153        self.signature_key_pair.private()
154    }
155
156    /// Get the signature scheme
157    pub fn signature_scheme(&self) -> SignatureScheme {
158        self.signature_key_pair.signature_scheme()
159    }
160
161    /// Get the cipher suite
162    pub fn cipher_suite(&self) -> CipherSuite {
163        self.cipher_suite
164    }
165
166    /// Generate a `CredentialWithKey`, which combines the credential type with the public portion of the keypair.
167    pub fn to_mls_credential_with_key(&self) -> CredentialWithKey {
168        CredentialWithKey {
169            credential: self.mls_credential.clone(),
170            signature_key: self.signature_key_pair.to_public_vec().into(),
171        }
172    }
173
174    /// Earliest valid time of creation for this credential.
175    ///
176    /// This is represented as seconds after the unix epoch.
177    ///
178    /// Only meaningful for X509, where it is the "valid_from" claim of the leaf credential.
179    /// For basic credentials, this is always 0 when the credential is first created.
180    /// It is updated upon being persisted to the database.
181    pub fn earliest_validity(&self) -> u64 {
182        self.earliest_validity
183    }
184
185    /// Get the client ID associated with this credential
186    pub fn client_id(&self) -> &ClientIdRef {
187        self.mls_credential.identity().into()
188    }
189}
190
191impl From<Credential> for CredentialWithKey {
192    fn from(cb: Credential) -> Self {
193        Self {
194            credential: cb.mls_credential,
195            signature_key: cb.signature_key_pair.public().into(),
196        }
197    }
198}
199
200impl Eq for Credential {}
201impl PartialEq for Credential {
202    fn eq(&self, other: &Self) -> bool {
203        self.mls_credential == other.mls_credential && self.earliest_validity == other.earliest_validity && {
204            let sk = &self.signature_key_pair;
205            let ok = &other.signature_key_pair;
206            sk.signature_scheme() == ok.signature_scheme() && sk.public() == ok.public()
207            // public key equality implies private key equality
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::{x509::CertificateBundle, *};
215    use crate::{
216        CredentialType, E2eiConversationState,
217        mls::credential::x509::CertificatePrivateKey,
218        mls_provider::PkiKeypair,
219        test_utils::{
220            x509::{CertificateParams, X509TestChain},
221            *,
222        },
223    };
224
225    #[apply(all_cred_cipher)]
226    async fn basic_clients_can_send_messages(case: TestContext) {
227        if !case.is_basic() {
228            return;
229        }
230        let [alice, bob] = case.sessions_basic().await;
231        let conversation = case.create_conversation([&alice, &bob]).await;
232        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
233    }
234
235    #[apply(all_cred_cipher)]
236    async fn certificate_clients_can_send_messages(case: TestContext) {
237        if !case.is_x509() {
238            return;
239        }
240        let [alice, bob] = case.sessions_x509().await;
241        let conversation = case.create_conversation([&alice, &bob]).await;
242        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
243    }
244
245    #[apply(all_cred_cipher)]
246    async fn heterogeneous_clients_can_send_messages(case: TestContext) {
247        // check that both credentials can initiate/join a group
248        let ([x509_session], [basic_session]) = case.sessions_mixed_credential_types().await;
249
250        // That way the conversation creator (Alice) will have a different credential type than Bob
251        let (alice, bob) = match case.credential_type {
252            CredentialType::Basic => (x509_session, basic_session),
253            CredentialType::X509 => (basic_session, x509_session),
254        };
255
256        let conversation = case.create_conversation([&alice, &bob]).await;
257        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
258    }
259
260    #[apply(all_cred_cipher)]
261    async fn should_fail_when_certificate_chain_is_empty(case: TestContext) {
262        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
263
264        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
265
266        let mut cert = CertificateBundle::rand(&"alice".into(), x509_intermediate);
267        cert.certificate_chain = vec![];
268        let err = Credential::x509(case.cipher_suite(), cert).unwrap_err();
269
270        assert!(innermost_source_matches!(err, Error::InvalidIdentity));
271    }
272
273    #[apply(all_cred_cipher)]
274    async fn should_fail_when_signature_key_doesnt_match_certificate_public_key(case: TestContext) {
275        if !case.is_x509() {
276            return;
277        }
278        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
279        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
280
281        let certs = CertificateBundle::rand(&"alice".into(), x509_intermediate);
282        let new_pki_kp = PkiKeypair::rand(case.signature_scheme(), CRYPTO.as_ref()).unwrap();
283
284        let eve_key = CertificatePrivateKey::new(new_pki_kp.signing_key_bytes());
285        let cb = CertificateBundle {
286            certificate_chain: certs.certificate_chain,
287            private_key: eve_key,
288            signature_scheme: case.cipher_suite().signature_algorithm(),
289        };
290        let err = Credential::x509(case.cipher_suite(), cb).unwrap_err();
291
292        assert!(innermost_source_matches!(
293            err,
294            crate::OpenMlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::MismatchKeypair),
295        ));
296    }
297
298    #[apply(all_cred_cipher)]
299    async fn should_fail_when_certificate_signature_doesnt_match_ciphersuite(case: TestContext) {
300        use openmls::prelude::Ciphersuite;
301        if !case.is_x509() {
302            return;
303        }
304
305        let [alice] = case.sessions_x509().await;
306        let conversation = case.create_conversation([&alice]).await;
307
308        let other_cipher_suite = match case.cipher_suite() {
309            CipherSuite(Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) => {
310                CipherSuite(Ciphersuite::MLS_128_DHKEMP256_AES128GCM_SHA256_P256)
311            }
312            _ => CipherSuite(Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519),
313        };
314
315        let mixed_cipher_suite_case = TestContext::new(CredentialType::X509, *other_cipher_suite);
316        let [bob] = mixed_cipher_suite_case.sessions_x509().await;
317
318        let bob_kp = bob
319            .transaction
320            .generate_key_package(&bob.initial_credential, None)
321            .await
322            .unwrap()
323            .into();
324
325        assert!(conversation.guard().await.add_members(vec![bob_kp]).await.is_err());
326    }
327
328    #[apply(all_cred_cipher)]
329    async fn should_not_fail_but_degrade_when_certificate_expired(case: TestContext) {
330        if !case.is_x509() {
331            return;
332        }
333        Box::pin(async move {
334            let mut x509_test_chain = case.set_test_chain(&[], &[], None).await;
335            let expiration_time = core::time::Duration::from_secs(5);
336            let start = web_time::Instant::now();
337
338            let alice_cert = x509_test_chain.issue_simple_certificate_bundle("alice", None);
339            let alice_cred = Credential::x509(case.cipher_suite(), alice_cert).unwrap();
340            let bob_cert = x509_test_chain.issue_simple_certificate_bundle("bob", Some(expiration_time));
341            let bob_cred = Credential::x509(case.cipher_suite(), bob_cert).unwrap();
342            let alice = SessionContext::new_with_credential(&case, alice_cred, case.sessions_in_memory)
343                .await
344                .unwrap();
345            let bob = SessionContext::new_with_credential(&case, bob_cred, case.sessions_in_memory)
346                .await
347                .unwrap();
348
349            let conversation = case.create_conversation([&alice, &bob]).await;
350            // this should work since the certificate is not yet expired
351            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
352
353            assert_eq!(
354                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
355                E2eiConversationState::Verified
356            );
357
358            let elapsed = start.elapsed();
359            // Give time to the certificate to expire
360            if expiration_time > elapsed {
361                smol::Timer::after(expiration_time - elapsed + core::time::Duration::from_secs(1)).await;
362            }
363
364            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
365            assert_eq!(
366                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
367                E2eiConversationState::NotVerified
368            );
369        })
370        .await;
371    }
372
373    #[apply(all_cred_cipher)]
374    async fn should_not_fail_but_degrade_when_basic_joins(case: TestContext) {
375        if !case.is_x509() {
376            return;
377        }
378        Box::pin(async {
379            let ([alice, bob], [charlie]) = case.sessions_mixed_credential_types().await;
380            let conversation = case.create_conversation([&alice, &bob]).await;
381
382            // this should work since the certificate is not yet expired
383            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
384            assert_eq!(
385                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
386                E2eiConversationState::Verified
387            );
388            assert_eq!(
389                conversation
390                    .guard_of(&bob)
391                    .await
392                    .e2ei_conversation_state()
393                    .await
394                    .unwrap(),
395                E2eiConversationState::Verified
396            );
397
398            // Charlie is a basic client that tries to join (i.e. emulates guest links in Wire)
399            let conversation = conversation
400                .invite_with_credential_notify([(&charlie, &charlie.initial_credential)])
401                .await;
402
403            assert_eq!(
404                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
405                E2eiConversationState::NotVerified
406            );
407            assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
408            assert_eq!(
409                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
410                E2eiConversationState::NotVerified
411            );
412        })
413        .await;
414    }
415
416    #[apply(all_cred_cipher)]
417    async fn should_fail_when_certificate_not_valid_yet(case: TestContext) {
418        use crate::OpenMlsErrorKind;
419
420        if !case.is_x509() {
421            return;
422        }
423        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
424
425        let tomorrow = now_std() + core::time::Duration::from_secs(3600 * 24);
426        let local_ca = x509_test_chain.find_local_intermediate_ca();
427        let alice_cert = {
428            let name = "alice";
429            let common_name = format!("{name} Smith");
430            let handle = format!("{}_wire", name.to_lowercase());
431            let [client_id] = case.client_ids();
432            local_ca.create_and_sign_end_identity(CertificateParams {
433                common_name: Some(common_name.clone()),
434                handle: Some(handle.clone()),
435                client_id: Some(client_id.clone()),
436                validity_start: Some(tomorrow),
437                ..Default::default()
438            })
439        };
440        let cb = CertificateBundle::from_certificate_and_issuer(&alice_cert, local_ca);
441        let err = Credential::x509(case.cipher_suite(), cb).unwrap_err();
442
443        assert!(innermost_source_matches!(
444            err,
445            OpenMlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::ExpiredCertificate),
446        ))
447    }
448
449    /// In order to be WASM-compatible
450    // pub fn now() -> wire_e2e_identity::OffsetDateTime {
451    //     let now_since_epoch = now_std().as_secs() as i64;
452    //     wire_e2e_identity::OffsetDateTime::from_unix_timestamp(now_since_epoch).unwrap()
453    // }
454    pub(crate) fn now_std() -> std::time::Duration {
455        let now = web_time::SystemTime::now();
456        now.duration_since(web_time::UNIX_EPOCH).unwrap()
457    }
458}