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 point at which this credential is valid, in seconds since the unix epoch.
49    ///
50    /// For an X509 credential this is the leaf certificate's `not_before` claim. A basic credential has
51    /// no such claim: it reads 0 until the credential is persisted, and the insertion time thereafter.
52    pub(crate) earliest_validity: u64,
53}
54
55impl TryFrom<Arc<StoredCredential>> for Credential {
56    type Error = Error;
57
58    fn try_from(stored_credential: Arc<StoredCredential>) -> Result<Credential> {
59        let mls_credential = MlsCredential::tls_deserialize(&mut stored_credential.credential.as_slice())
60            .map_err(TlsCodecError::deserialize("mls credential"))?;
61        let cipher_suite = CipherSuite::try_from(stored_credential.ciphersuite)?;
62        let signature_key_pair = openmls_basic_credential::SignatureKeyPair::from_raw(
63            cipher_suite.signature_algorithm(),
64            stored_credential.private_key.to_owned(),
65            stored_credential.public_key.to_owned(),
66        );
67        let credential_type = mls_credential
68            .credential_type()
69            .try_into()
70            .map_err(RecursiveError::context("loading credential from db"))?;
71        let earliest_validity = stored_credential.created_at;
72        Ok(Credential {
73            cipher_suite,
74            signature_key_pair,
75            credential_type,
76            mls_credential,
77            earliest_validity,
78        })
79    }
80}
81
82impl Credential {
83    /// Generate a basic credential.
84    ///
85    /// The result is independent of any client instance and the database; it lives in memory only.
86    ///
87    /// The earliest validity of this credential is always 0. It will be updated once the credential is added to a
88    /// session.
89    pub fn basic(cipher_suite: CipherSuite, client_id: ClientId) -> Result<Self> {
90        let signature_scheme = cipher_suite.signature_algorithm();
91        let (private_key, public_key) = CRYPTO
92            .signature_key_gen(signature_scheme)
93            .map_err(OpenMlsError::wrap("generating signature key"))?;
94        let signature_key_pair = SignatureKeyPair::from_raw(signature_scheme, private_key, public_key);
95
96        Ok(Self {
97            cipher_suite,
98            credential_type: CredentialType::Basic,
99            mls_credential: MlsCredential::new_basic(client_id.into_inner()),
100            signature_key_pair,
101            earliest_validity: 0,
102        })
103    }
104
105    /// Recover the public half of the credential which produced a key package.
106    ///
107    /// The result carries no private signature key, so it can be persisted and it identifies its
108    /// client, but it can never sign. That is all a history client ever has: the private key of its
109    /// credential is discarded as soon as the history secret is generated, which is what makes
110    /// such a client incapable of producing messages of its own.
111    pub(crate) fn public_only(key_package: &KeyPackage) -> Result<Self> {
112        let cipher_suite = CipherSuite::from(key_package.ciphersuite());
113        let leaf_node = key_package.leaf_node();
114        let mls_credential = leaf_node.credential().clone();
115        let credential_type = mls_credential.credential_type().try_into()?;
116        let signature_key_pair = SignatureKeyPair::from_raw(
117            cipher_suite.signature_algorithm(),
118            Vec::new(),
119            leaf_node.signature_key().as_slice().to_vec(),
120        );
121
122        Ok(Self {
123            cipher_suite,
124            credential_type,
125            mls_credential,
126            signature_key_pair,
127            earliest_validity: 0,
128        })
129    }
130
131    /// Get the Openmls Credential type.
132    ///
133    /// This stores the credential type (basic/x509).
134    pub fn mls_credential(&self) -> &MlsCredential {
135        &self.mls_credential
136    }
137
138    /// Get the credential type
139    pub fn credential_type(&self) -> CredentialType {
140        self.credential_type
141    }
142
143    /// Get a reference to the `SignatureKeyPair`.
144    pub(crate) fn signature_key(&self) -> &SignatureKeyPair {
145        &self.signature_key_pair
146    }
147
148    /// The **private** half of this credential's signature key.
149    pub fn signature_key_bytes(&self) -> &[u8] {
150        self.signature_key_pair.private()
151    }
152
153    /// Get the signature scheme
154    pub fn signature_scheme(&self) -> SignatureScheme {
155        self.signature_key_pair.signature_scheme()
156    }
157
158    /// Get the cipher suite
159    pub fn cipher_suite(&self) -> CipherSuite {
160        self.cipher_suite
161    }
162
163    /// Generate a `CredentialWithKey`, which combines the credential type with the public portion of the keypair.
164    pub fn to_mls_credential_with_key(&self) -> CredentialWithKey {
165        CredentialWithKey {
166            credential: self.mls_credential.clone(),
167            signature_key: self.signature_key_pair.to_public_vec().into(),
168        }
169    }
170
171    /// Earliest point at which this credential is valid, in seconds since the unix epoch.
172    ///
173    /// For an X509 credential this is the leaf certificate's `not_before` claim. A basic credential has
174    /// no such claim: it reads 0 until the credential is persisted, and the insertion time thereafter.
175    pub fn earliest_validity(&self) -> u64 {
176        self.earliest_validity
177    }
178
179    /// Get the client ID associated with this credential
180    pub fn client_id(&self) -> &ClientIdRef {
181        self.mls_credential.identity().into()
182    }
183}
184
185impl From<Credential> for CredentialWithKey {
186    fn from(cb: Credential) -> Self {
187        Self {
188            credential: cb.mls_credential,
189            signature_key: cb.signature_key_pair.public().into(),
190        }
191    }
192}
193
194impl Eq for Credential {}
195impl PartialEq for Credential {
196    fn eq(&self, other: &Self) -> bool {
197        self.mls_credential == other.mls_credential && self.earliest_validity == other.earliest_validity && {
198            let sk = &self.signature_key_pair;
199            let ok = &other.signature_key_pair;
200            sk.signature_scheme() == ok.signature_scheme() && sk.public() == ok.public()
201            // public key equality implies private key equality
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use rusty_jwt_tools::jsonwebtoken::DecodingKey;
209    use wire_e2e_identity::CiphersuiteExt as _;
210
211    use super::{x509::CertificateBundle, *};
212    use crate::{
213        CredentialType, E2eiConversationState, MlsCiphersuite,
214        mls::credential::x509::CertificatePrivateKey,
215        test_utils::{
216            x509::{CertificateParams, PkiKeypair, X509TestChain},
217            *,
218        },
219    };
220
221    #[apply(all_cred_cipher)]
222    async fn basic_clients_can_send_messages(case: TestContext) {
223        if !case.is_basic() {
224            return;
225        }
226        let [alice, bob] = case.sessions_basic().await;
227        let conversation = case.create_conversation([&alice, &bob]).await;
228        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
229    }
230
231    #[apply(all_cred_cipher)]
232    async fn certificate_clients_can_send_messages(case: TestContext) {
233        if !case.is_x509() {
234            return;
235        }
236        let [alice, bob] = case.sessions_x509().await;
237        let conversation = case.create_conversation([&alice, &bob]).await;
238        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
239    }
240
241    /// Certificate acquisition converts the raw private keys of our MLS provider to PEM with rusty-jwt-tools.
242    #[apply(all_cred_cipher)]
243    async fn signature_key_should_convert_to_pem(case: TestContext) {
244        if !case.is_basic() {
245            return;
246        }
247        // ciphersuites without a JWS algorithm don't support certificate acquisition
248        let Some(alg) = MlsCiphersuite::from(case.cipher_suite()).jws_algorithm() else {
249            return;
250        };
251        let [client_id] = case.client_ids();
252        let credential = Credential::basic(case.cipher_suite(), client_id).unwrap();
253
254        let pem = alg.keypair_from_bytes(credential.signature_key_bytes()).unwrap();
255        let jwk = alg.public_jwk(&pem).unwrap();
256        let public_key = DecodingKey::from_jwk(&jwk).unwrap();
257        assert_eq!(
258            public_key.try_get_as_bytes().unwrap(),
259            credential.signature_key().public()
260        );
261    }
262
263    #[apply(all_cred_cipher)]
264    async fn heterogeneous_clients_can_send_messages(case: TestContext) {
265        if !case.supports_x509() {
266            return;
267        }
268
269        // check that both credentials can initiate/join a group
270        let ([x509_session], [basic_session]) = case.sessions_mixed_credential_types().await;
271
272        // That way the conversation creator (Alice) will have a different credential type than Bob
273        let (alice, bob) = match case.credential_type {
274            CredentialType::Basic => (x509_session, basic_session),
275            CredentialType::X509 => (basic_session, x509_session),
276        };
277
278        let conversation = case.create_conversation([&alice, &bob]).await;
279        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
280    }
281
282    #[apply(all_cred_cipher)]
283    async fn should_fail_when_certificate_chain_is_empty(case: TestContext) {
284        if !case.supports_x509() {
285            return;
286        }
287        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
288
289        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
290
291        let mut cert = CertificateBundle::rand(&"alice".into(), x509_intermediate);
292        cert.certificate_chain = vec![];
293        let err = Credential::x509(case.cipher_suite(), cert).unwrap_err();
294
295        assert!(innermost_source_matches!(err, Error::InvalidIdentity));
296    }
297
298    #[apply(all_cred_cipher)]
299    async fn should_fail_when_signature_key_doesnt_match_certificate_public_key(case: TestContext) {
300        if !case.is_x509() {
301            return;
302        }
303        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
304        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
305
306        let certs = CertificateBundle::rand(&"alice".into(), x509_intermediate);
307        let new_keypair = CRYPTO.signature_key_gen(case.signature_scheme()).unwrap();
308        let new_pki_kp = PkiKeypair::new(case.signature_scheme(), new_keypair.0).unwrap();
309
310        let eve_key = CertificatePrivateKey::new(new_pki_kp.signing_key_bytes());
311        let cb = CertificateBundle {
312            certificate_chain: certs.certificate_chain,
313            private_key: eve_key,
314            signature_scheme: case.cipher_suite().signature_algorithm(),
315        };
316        let err = Credential::x509(case.cipher_suite(), cb).unwrap_err();
317
318        assert!(innermost_source_matches!(
319            err,
320            crate::OpenMlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::MismatchKeypair),
321        ));
322    }
323
324    #[apply(all_cred_cipher)]
325    async fn should_fail_when_certificate_signature_doesnt_match_ciphersuite(case: TestContext) {
326        use openmls::prelude::Ciphersuite;
327        if !case.is_x509() {
328            return;
329        }
330
331        let [alice] = case.sessions_x509().await;
332        let conversation = case.create_conversation([&alice]).await;
333
334        let other_cipher_suite = match case.cipher_suite() {
335            CipherSuite(Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) => {
336                CipherSuite(Ciphersuite::MLS_128_DHKEMP256_AES128GCM_SHA256_P256)
337            }
338            _ => CipherSuite(Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519),
339        };
340
341        let mixed_cipher_suite_case = TestContext::new(CredentialType::X509, *other_cipher_suite);
342        let [bob] = mixed_cipher_suite_case.sessions_x509().await;
343
344        let bob_kp = bob
345            .transaction
346            .generate_key_package(&bob.initial_credential, None)
347            .await
348            .unwrap()
349            .into();
350
351        assert!(conversation.guard().await.add_members(vec![bob_kp]).await.is_err());
352    }
353
354    #[apply(all_cred_cipher)]
355    async fn should_not_fail_but_degrade_when_certificate_expired(case: TestContext) {
356        if !case.is_x509() {
357            return;
358        }
359        let mut x509_test_chain = case.set_test_chain(&[], &[], None).await;
360        let expiration_time = core::time::Duration::from_secs(8);
361        let start = web_time::Instant::now();
362
363        let alice_cert = x509_test_chain.issue_simple_certificate_bundle("alice", None);
364        let alice_cred = Credential::x509(case.cipher_suite(), alice_cert).unwrap();
365        let bob_cert = x509_test_chain.issue_simple_certificate_bundle("bob", Some(expiration_time));
366        let bob_cred = Credential::x509(case.cipher_suite(), bob_cert).unwrap();
367        let alice = SessionContext::new_with_credential(&case, alice_cred, case.sessions_in_memory)
368            .await
369            .unwrap();
370        let bob = SessionContext::new_with_credential(&case, bob_cred, case.sessions_in_memory)
371            .await
372            .unwrap();
373
374        let conversation = case.create_conversation([&alice, &bob]).await;
375        // this should work since the certificate is not yet expired
376        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
377
378        assert_eq!(
379            conversation.guard().await.e2ei_conversation_state().await.unwrap(),
380            E2eiConversationState::Verified
381        );
382
383        let elapsed = start.elapsed();
384        // Give time to the certificate to expire
385        if expiration_time > elapsed {
386            smol::Timer::after(expiration_time - elapsed + core::time::Duration::from_secs(1)).await;
387        }
388
389        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
390        assert_eq!(
391            conversation.guard().await.e2ei_conversation_state().await.unwrap(),
392            E2eiConversationState::NotVerified
393        );
394    }
395
396    #[apply(all_cred_cipher)]
397    async fn should_not_fail_but_degrade_when_basic_joins(case: TestContext) {
398        if !case.is_x509() {
399            return;
400        }
401        Box::pin(async {
402            let ([alice, bob], [charlie]) = case.sessions_mixed_credential_types().await;
403            let conversation = case.create_conversation([&alice, &bob]).await;
404
405            // this should work since the certificate is not yet expired
406            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
407            assert_eq!(
408                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
409                E2eiConversationState::Verified
410            );
411            assert_eq!(
412                conversation
413                    .guard_of(&bob)
414                    .await
415                    .e2ei_conversation_state()
416                    .await
417                    .unwrap(),
418                E2eiConversationState::Verified
419            );
420
421            // Charlie is a basic client that tries to join (i.e. emulates guest links in Wire)
422            let conversation = conversation
423                .invite_with_credential_notify([(&charlie, &charlie.initial_credential)])
424                .await;
425
426            assert_eq!(
427                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
428                E2eiConversationState::NotVerified
429            );
430            assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
431            assert_eq!(
432                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
433                E2eiConversationState::NotVerified
434            );
435        })
436        .await;
437    }
438
439    /// Persisting an X509 credential must not overwrite the certificate's `not_before` claim.
440    ///
441    /// `Credential::save` used to stamp every credential with the insertion time, which discarded
442    /// the claim `Credential::x509` had just computed and made `FindFilters::earliest_validity`
443    /// -- documented as "point of earliest validity" -- match nothing.
444    #[apply(all_cred_cipher)]
445    async fn saving_an_x509_credential_keeps_the_certificate_validity(case: TestContext) {
446        if !case.is_x509() {
447            return;
448        }
449        let [alice] = case.sessions().await;
450        Box::pin(async move {
451            let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
452            let local_ca = x509_test_chain.find_local_intermediate_ca();
453
454            // An hour in the past, so a stamp of "now" is unmistakably different.
455            let an_hour_ago = now_std() - core::time::Duration::from_secs(3600);
456            // Must be alice's own client id: `add_credential` rejects a credential belonging to
457            // anyone else.
458            let client_id = alice.get_client_id().await;
459            let cert = local_ca.create_and_sign_end_identity(CertificateParams {
460                common_name: Some("Alice Smith".to_string()),
461                handle: Some("alice_wire".to_string()),
462                client_id: Some(client_id),
463                validity_start: Some(an_hour_ago),
464                ..Default::default()
465            });
466            let bundle = CertificateBundle::from_certificate_and_issuer(&cert, local_ca);
467            let credential = Credential::x509(case.cipher_suite(), bundle).unwrap();
468
469            // The fixture's conversion of `validity_start` can be off by a second, so allow a
470            // little slack here; what matters is that this is an hour ago and not "now".
471            let from_certificate = credential.earliest_validity();
472            assert!(
473                from_certificate.abs_diff(an_hour_ago.as_secs()) <= 5,
474                "the credential must start out carrying the certificate's `not_before`: \
475                 got {from_certificate}, expected about {}",
476                an_hour_ago.as_secs()
477            );
478
479            let credential_ref = alice
480                .add_credential(credential)
481                .await
482                .expect("adding the credential should succeed");
483
484            assert_eq!(
485                credential_ref.earliest_validity(),
486                from_certificate,
487                "persisting must not replace the certificate claim with the insertion time"
488            );
489        })
490        .await;
491    }
492
493    #[apply(all_cred_cipher)]
494    async fn should_fail_when_certificate_not_valid_yet(case: TestContext) {
495        use crate::OpenMlsErrorKind;
496
497        if !case.is_x509() {
498            return;
499        }
500        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
501
502        let tomorrow = now_std() + core::time::Duration::from_secs(3600 * 24);
503        let local_ca = x509_test_chain.find_local_intermediate_ca();
504        let alice_cert = {
505            let name = "alice";
506            let common_name = format!("{name} Smith");
507            let handle = format!("{}_wire", name.to_lowercase());
508            let [client_id] = case.client_ids();
509            local_ca.create_and_sign_end_identity(CertificateParams {
510                common_name: Some(common_name.clone()),
511                handle: Some(handle.clone()),
512                client_id: Some(client_id.clone()),
513                validity_start: Some(tomorrow),
514                ..Default::default()
515            })
516        };
517        let cb = CertificateBundle::from_certificate_and_issuer(&alice_cert, local_ca);
518        let err = Credential::x509(case.cipher_suite(), cb).unwrap_err();
519
520        assert!(innermost_source_matches!(
521            err,
522            OpenMlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::ExpiredCertificate),
523        ))
524    }
525
526    /// In order to be WASM-compatible
527    // pub fn now() -> wire_e2e_identity::OffsetDateTime {
528    //     let now_since_epoch = now_std().as_secs() as i64;
529    //     wire_e2e_identity::OffsetDateTime::from_unix_timestamp(now_since_epoch).unwrap()
530    // }
531    pub(crate) fn now_std() -> std::time::Duration {
532        let now = web_time::SystemTime::now();
533        now.duration_since(web_time::UNIX_EPOCH).unwrap()
534    }
535}