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