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;
9pub(crate) mod ext;
10mod persistence;
11pub(crate) mod x509;
12
13use core_crypto_keystore::entities::StoredCredential;
14use openmls::prelude::{Credential as MlsCredential, CredentialWithKey, SignatureScheme};
15use openmls_basic_credential::SignatureKeyPair;
16use openmls_traits::crypto::OpenMlsCrypto;
17use tls_codec::Deserialize as _;
18
19pub(crate) use self::error::Result;
20pub use self::{
21    credential_ref::{CredentialRef, FindFilters, FindFiltersBuilder},
22    credential_type::CredentialType,
23    error::Error,
24};
25use crate::{Ciphersuite, ClientId, ClientIdRef, ClientIdentifier, MlsError, RecursiveError, mls_provider::CRYPTO};
26
27/// A cryptographic credential.
28///
29/// This is tied to a particular client via either its client id or certificate bundle,
30/// depending on its credential type, but is independent of any client instance or storage.
31///
32/// To attach to a particular client instance and store, see
33/// [`TransactionContext::add_credential`][crate::transaction_context::TransactionContext::add_credential].
34#[derive(core_crypto_macros::Debug, Clone, serde::Serialize, serde::Deserialize)]
35pub struct Credential {
36    /// Ciphersuite used by this credential
37    pub(crate) ciphersuite: Ciphersuite,
38    /// Credential type
39    pub(crate) credential_type: CredentialType,
40    /// MLS internal credential. Stores the MLS credential
41    pub(crate) mls_credential: MlsCredential,
42    /// Public and private keys, and the signature scheme.
43    #[sensitive]
44    pub(crate) signature_key_pair: SignatureKeyPair,
45    /// Earliest valid time of creation for this credential.
46    ///
47    /// This is represented as seconds after the unix epoch.
48    ///
49    /// Only meaningful for X509, where it is the "valid_from" claim of the leaf credential.
50    /// For basic credentials, this is always 0.
51    pub(crate) earliest_validity: u64,
52}
53
54impl TryFrom<&StoredCredential> for Credential {
55    type Error = Error;
56
57    fn try_from(stored_credential: &StoredCredential) -> Result<Credential> {
58        let mls_credential = MlsCredential::tls_deserialize(&mut stored_credential.credential.as_slice())
59            .map_err(Error::tls_deserialize("mls credential"))?;
60        let ciphersuite = Ciphersuite::try_from(stored_credential.ciphersuite)
61            .map_err(RecursiveError::mls("loading ciphersuite from db"))?;
62        let signature_key_pair = openmls_basic_credential::SignatureKeyPair::from_raw(
63            ciphersuite.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::mls_credential("loading credential from db"))?;
71        let earliest_validity = stored_credential.created_at;
72        Ok(Credential {
73            ciphersuite,
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(ciphersuite: Ciphersuite, client_id: ClientId) -> Result<Self> {
90        let signature_scheme = ciphersuite.signature_algorithm();
91        let (private_key, public_key) = CRYPTO
92            .signature_key_gen(signature_scheme)
93            .map_err(MlsError::wrap("generating signature key"))?;
94        let signature_key_pair = SignatureKeyPair::from_raw(signature_scheme, private_key, public_key);
95
96        Ok(Self {
97            ciphersuite,
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    /// Get the Openmls Credential type.
106    ///
107    /// This stores the credential type (basic/x509).
108    pub fn mls_credential(&self) -> &MlsCredential {
109        &self.mls_credential
110    }
111
112    /// Get the credential type
113    pub fn credential_type(&self) -> CredentialType {
114        self.credential_type
115    }
116
117    /// Get a reference to the `SignatureKeyPair`.
118    pub(crate) fn signature_key(&self) -> &SignatureKeyPair {
119        &self.signature_key_pair
120    }
121
122    /// Get the signature scheme
123    pub fn signature_scheme(&self) -> SignatureScheme {
124        self.signature_key_pair.signature_scheme()
125    }
126
127    /// Get the ciphersuite
128    pub fn ciphersuite(&self) -> Ciphersuite {
129        self.ciphersuite
130    }
131
132    /// Generate a `CredentialWithKey`, which combines the credential type with the public portion of the keypair.
133    pub fn to_mls_credential_with_key(&self) -> CredentialWithKey {
134        CredentialWithKey {
135            credential: self.mls_credential.clone(),
136            signature_key: self.signature_key_pair.to_public_vec().into(),
137        }
138    }
139
140    /// Earliest valid time of creation for this credential.
141    ///
142    /// This is represented as seconds after the unix epoch.
143    ///
144    /// Only meaningful for X509, where it is the "valid_from" claim of the leaf credential.
145    /// For basic credentials, this is always 0 when the credential is first created.
146    /// It is updated upon being persisted to the database.
147    pub fn earliest_validity(&self) -> u64 {
148        self.earliest_validity
149    }
150
151    /// Get the client ID associated with this credential
152    pub fn client_id(&self) -> &ClientIdRef {
153        self.mls_credential.identity().into()
154    }
155}
156
157impl Credential {
158    /// Create a credential from an identifier
159    // currently only used in test code, but generally applicable
160    #[cfg_attr(not(test), expect(dead_code))]
161    pub(crate) fn from_identifier(identifier: &ClientIdentifier, ciphersuite: Ciphersuite) -> Result<Self> {
162        match identifier {
163            ClientIdentifier::Basic(client_id) => Self::basic(ciphersuite, client_id.clone()),
164            ClientIdentifier::X509(certs) => {
165                let signature_scheme = ciphersuite.signature_algorithm();
166                let cert = certs
167                    .get(&signature_scheme)
168                    .ok_or(Error::SignatureSchemeNotPresentInX509Identity(signature_scheme))?;
169                Self::x509(ciphersuite, cert.clone())
170            }
171        }
172    }
173}
174
175impl From<Credential> for CredentialWithKey {
176    fn from(cb: Credential) -> Self {
177        Self {
178            credential: cb.mls_credential,
179            signature_key: cb.signature_key_pair.public().into(),
180        }
181    }
182}
183
184impl Eq for Credential {}
185impl PartialEq for Credential {
186    fn eq(&self, other: &Self) -> bool {
187        self.mls_credential == other.mls_credential && self.earliest_validity == other.earliest_validity && {
188            let sk = &self.signature_key_pair;
189            let ok = &other.signature_key_pair;
190            sk.signature_scheme() == ok.signature_scheme() && sk.public() == ok.public()
191            // public key equality implies private key equality
192        }
193    }
194}
195
196// TODO: ensure certificate signature must match the group's ciphersuite ; fails otherwise. Tracking issue: WPB-9632
197// Requires more than 1 ciphersuite supported at the moment.
198#[cfg(test)]
199mod tests {
200    use std::collections::HashMap;
201
202    use super::{x509::CertificateBundle, *};
203    use crate::{
204        ClientIdentifier, CredentialType, E2eiConversationState,
205        mls::{conversation::Conversation as _, credential::x509::CertificatePrivateKey},
206        mls_provider::PkiKeypair,
207        test_utils::{
208            x509::{CertificateParams, X509TestChain},
209            *,
210        },
211    };
212
213    #[apply(all_cred_cipher)]
214    async fn basic_clients_can_send_messages(case: TestContext) {
215        if !case.is_basic() {
216            return;
217        }
218        let [alice, bob] = case.sessions_basic().await;
219        let conversation = case.create_conversation([&alice, &bob]).await;
220        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
221    }
222
223    #[apply(all_cred_cipher)]
224    async fn certificate_clients_can_send_messages(case: TestContext) {
225        if !case.is_x509() {
226            return;
227        }
228        let [alice, bob] = case.sessions_x509().await;
229        let conversation = case.create_conversation([&alice, &bob]).await;
230        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
231    }
232
233    #[apply(all_cred_cipher)]
234    async fn heterogeneous_clients_can_send_messages(case: TestContext) {
235        // check that both credentials can initiate/join a group
236        let ([x509_session], [basic_session]) = case.sessions_mixed_credential_types().await;
237
238        // That way the conversation creator (Alice) will have a different credential type than Bob
239        let (alice, bob) = match case.credential_type {
240            CredentialType::Basic => (x509_session, basic_session),
241            CredentialType::X509 => (basic_session, x509_session),
242        };
243
244        let conversation = case.create_conversation([&alice, &bob]).await;
245        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
246    }
247
248    #[apply(all_cred_cipher)]
249    async fn should_fail_when_certificate_chain_is_empty(case: TestContext) {
250        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
251
252        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
253
254        let mut certs = CertificateBundle::rand(&"alice".into(), x509_intermediate);
255        certs.certificate_chain = vec![];
256        let alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), certs)]));
257
258        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
259            .await
260            .unwrap_err();
261        assert!(innermost_source_matches!(err, Error::InvalidIdentity));
262    }
263
264    #[apply(all_cred_cipher)]
265    async fn should_fail_when_certificate_chain_has_a_single_self_signed(case: TestContext) {
266        use crate::MlsErrorKind;
267
268        if !case.is_x509() {
269            return;
270        }
271        let mut x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
272
273        let (_alice_identifier, alice_cert) = x509_test_chain.issue_simple_certificate_bundle("alice", None);
274
275        let new_cert = alice_cert
276            .pki_keypair
277            .re_sign(&alice_cert.certificate, &alice_cert.certificate, None)
278            .unwrap();
279        let mut alice_cert = alice_cert.clone();
280        alice_cert.certificate = new_cert;
281        let cb = CertificateBundle::from_self_signed_certificate(&alice_cert);
282        let alice_identifier = ClientIdentifier::X509([(case.signature_scheme(), cb)].into());
283
284        let alice = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
285            .await
286            .unwrap();
287        let [bob] = case.sessions_x509().await;
288        let bob_key_package = bob.new_keypackage(&case).await;
289        let conversation = case.create_conversation([&alice]).await;
290        let err = conversation
291            .guard()
292            .await
293            .add_members([bob_key_package.into()].into())
294            .await
295            .unwrap_err();
296        assert!(innermost_source_matches!(err, MlsErrorKind::MlsAddMembersError(_)));
297    }
298
299    #[apply(all_cred_cipher)]
300    async fn should_fail_when_signature_key_doesnt_match_certificate_public_key(case: TestContext) {
301        if !case.is_x509() {
302            return;
303        }
304        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
305        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
306
307        let certs = CertificateBundle::rand(&"alice".into(), x509_intermediate);
308        let new_pki_kp = PkiKeypair::rand_unchecked(case.signature_scheme());
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.ciphersuite().signature_algorithm(),
315        };
316        let alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), cb)]));
317
318        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
319            .await
320            .unwrap_err();
321        assert!(innermost_source_matches!(
322            err,
323            crate::MlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::MismatchKeypair),
324        ));
325    }
326
327    #[apply(all_cred_cipher)]
328    async fn should_not_fail_but_degrade_when_certificate_expired(case: TestContext) {
329        if !case.is_x509() {
330            return;
331        }
332        Box::pin(async move {
333            let mut x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
334
335            let expiration_time = core::time::Duration::from_secs(14);
336            let start = web_time::Instant::now();
337
338            let (alice_identifier, _) = x509_test_chain.issue_simple_certificate_bundle("alice", None);
339            let (bob_identifier, _) = x509_test_chain.issue_simple_certificate_bundle("bob", Some(expiration_time));
340            let alice = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
341                .await
342                .unwrap();
343            let bob = SessionContext::new_with_identifier(&case, bob_identifier, Some(&x509_test_chain))
344                .await
345                .unwrap();
346
347            let conversation = case.create_conversation([&alice, &bob]).await;
348            // this should work since the certificate is not yet expired
349            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
350
351            assert_eq!(
352                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
353                E2eiConversationState::Verified
354            );
355
356            let elapsed = start.elapsed();
357            // Give time to the certificate to expire
358            if expiration_time > elapsed {
359                smol::Timer::after(expiration_time - elapsed + core::time::Duration::from_secs(2)).await;
360            }
361
362            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
363            assert_eq!(
364                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
365                E2eiConversationState::NotVerified
366            );
367        })
368        .await;
369    }
370
371    #[apply(all_cred_cipher)]
372    async fn should_not_fail_but_degrade_when_basic_joins(case: TestContext) {
373        if !case.is_x509() {
374            return;
375        }
376        Box::pin(async {
377            let ([alice, bob], [charlie]) = case.sessions_mixed_credential_types().await;
378            let conversation = case.create_conversation([&alice, &bob]).await;
379
380            // this should work since the certificate is not yet expired
381            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
382            assert_eq!(
383                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
384                E2eiConversationState::Verified
385            );
386            assert_eq!(
387                conversation
388                    .guard_of(&bob)
389                    .await
390                    .e2ei_conversation_state()
391                    .await
392                    .unwrap(),
393                E2eiConversationState::Verified
394            );
395
396            // Charlie is a basic client that tries to join (i.e. emulates guest links in Wire)
397            let conversation = conversation
398                .invite_with_credential_notify([(&charlie, &charlie.initial_credential)])
399                .await;
400
401            assert_eq!(
402                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
403                E2eiConversationState::NotVerified
404            );
405            assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
406            assert_eq!(
407                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
408                E2eiConversationState::NotVerified
409            );
410        })
411        .await;
412    }
413
414    #[apply(all_cred_cipher)]
415    async fn should_fail_when_certificate_not_valid_yet(case: TestContext) {
416        use crate::MlsErrorKind;
417
418        if !case.is_x509() {
419            return;
420        }
421        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
422
423        let tomorrow = now_std() + core::time::Duration::from_secs(3600 * 24);
424        let local_ca = x509_test_chain.find_local_intermediate_ca();
425        let alice_cert = {
426            let name = "alice";
427            let common_name = format!("{name} Smith");
428            let handle = format!("{}_wire", name.to_lowercase());
429            let client_id: String = crate::e2e_identity::id::QualifiedE2eiClientId::generate_with_domain("wire.com")
430                .try_into()
431                .unwrap();
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 alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), cb)]));
442        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
443            .await
444            .unwrap_err();
445
446        assert!(innermost_source_matches!(
447            err,
448            MlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::ExpiredCertificate),
449        ))
450    }
451
452    /// In order to be WASM-compatible
453    // pub fn now() -> wire_e2e_identity::OffsetDateTime {
454    //     let now_since_epoch = now_std().as_secs() as i64;
455    //     wire_e2e_identity::OffsetDateTime::from_unix_timestamp(now_since_epoch).unwrap()
456    // }
457    pub(crate) fn now_std() -> std::time::Duration {
458        let now = web_time::SystemTime::now();
459        now.duration_since(web_time::UNIX_EPOCH).unwrap()
460    }
461}