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, MlsCredentialType, 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::{
26    Ciphersuite, ClientId, ClientIdRef, ClientIdentifier, MlsError, RecursiveError,
27    mls::credential::{error::CredentialValidationError, ext::CredentialExt as _},
28};
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///
38/// Note: the current database design makes some questionable assumptions:
39///
40/// - There are always either 0 or 1 `StoredSignatureKeypair` instances in the DB for a particular signature scheme
41/// - There may be multiple `StoredCredential` instances in the DB for a particular signature scheme, but they all share
42///   the same `ClientId` / signing key. In other words, the same signing keypair is _reused_ between credentials.
43/// - Practically, the code ensures that there is a 1:1 correspondence between signing scheme <-> identity/credential,
44///   and we need to maintain that property for now.
45///
46/// Work is ongoing to fix those limitations; see WPB-20844. Until that is resolved, we enforce those restrictions by
47/// raising errors as required to preserve DB integrity.
48#[derive(core_crypto_macros::Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct Credential {
50    /// Ciphersuite used by this credential
51    pub(crate) ciphersuite: Ciphersuite,
52    /// Credential type
53    pub(crate) credential_type: CredentialType,
54    /// MLS internal credential. Stores the MLS credential
55    pub(crate) mls_credential: MlsCredential,
56    /// Public and private keys, and the signature scheme.
57    #[sensitive]
58    pub(crate) signature_key_pair: SignatureKeyPair,
59    /// Earliest valid time of creation for this credential.
60    ///
61    /// This is represented as seconds after the unix epoch.
62    ///
63    /// Only meaningful for X509, where it is the "valid_from" claim of the leaf credential.
64    /// For basic credentials, this is always 0.
65    pub(crate) earliest_validity: u64,
66}
67
68impl TryFrom<&StoredCredential> for Credential {
69    type Error = Error;
70
71    fn try_from(stored_credential: &StoredCredential) -> Result<Credential> {
72        let mls_credential = MlsCredential::tls_deserialize(&mut stored_credential.credential.as_slice())
73            .map_err(Error::tls_deserialize("mls credential"))?;
74        let ciphersuite = Ciphersuite::try_from(stored_credential.ciphersuite)
75            .map_err(RecursiveError::mls("loading ciphersuite from db"))?;
76        let signature_key_pair = openmls_basic_credential::SignatureKeyPair::from_raw(
77            ciphersuite.signature_algorithm(),
78            stored_credential.private_key.to_owned(),
79            stored_credential.public_key.to_owned(),
80        );
81        let credential_type = mls_credential
82            .credential_type()
83            .try_into()
84            .map_err(RecursiveError::mls_credential("loading credential from db"))?;
85        let earliest_validity = stored_credential.created_at;
86        Ok(Credential {
87            ciphersuite,
88            signature_key_pair,
89            credential_type,
90            mls_credential,
91            earliest_validity,
92        })
93    }
94}
95
96impl Credential {
97    /// Ensure that the provided `MlsCredential` matches the client id / signature key provided
98    pub(crate) fn validate_mls_credential(
99        mls_credential: &MlsCredential,
100        client_id: &ClientIdRef,
101        signature_key: &SignatureKeyPair,
102    ) -> Result<(), CredentialValidationError> {
103        match mls_credential.mls_credential() {
104            MlsCredentialType::Basic(_) => {
105                if client_id.as_slice() != mls_credential.identity() {
106                    return Err(CredentialValidationError::WrongCredential);
107                }
108            }
109            MlsCredentialType::X509(cert) => {
110                let certificate_public_key = cert
111                    .extract_public_key()
112                    .map_err(RecursiveError::mls_credential(
113                        "extracting public key from certificate in credential validation",
114                    ))?
115                    .ok_or(CredentialValidationError::NoPublicKey)?;
116                if signature_key.public() != certificate_public_key {
117                    return Err(CredentialValidationError::WrongCredential);
118                }
119            }
120        }
121        Ok(())
122    }
123
124    /// Generate a basic credential.
125    ///
126    /// The result is independent of any client instance and the database; it lives in memory only.
127    ///
128    /// The earliest validity of this credential is always 0. It will be updated once the credential is added to a
129    /// session.
130    pub fn basic(ciphersuite: Ciphersuite, client_id: ClientId, crypto: impl OpenMlsCrypto) -> Result<Self> {
131        let signature_scheme = ciphersuite.signature_algorithm();
132        let (private_key, public_key) = crypto
133            .signature_key_gen(signature_scheme)
134            .map_err(MlsError::wrap("generating signature key"))?;
135        let signature_key_pair = SignatureKeyPair::from_raw(signature_scheme, private_key, public_key);
136
137        Ok(Self {
138            ciphersuite,
139            credential_type: CredentialType::Basic,
140            mls_credential: MlsCredential::new_basic(client_id.into_inner()),
141            signature_key_pair,
142            earliest_validity: 0,
143        })
144    }
145
146    /// Get the Openmls Credential type.
147    ///
148    /// This stores the credential type (basic/x509).
149    pub fn mls_credential(&self) -> &MlsCredential {
150        &self.mls_credential
151    }
152
153    /// Get the credential type
154    pub fn credential_type(&self) -> CredentialType {
155        self.credential_type
156    }
157
158    /// Get a reference to the `SignatureKeyPair`.
159    pub(crate) fn signature_key(&self) -> &SignatureKeyPair {
160        &self.signature_key_pair
161    }
162
163    /// Get the signature scheme
164    pub fn signature_scheme(&self) -> SignatureScheme {
165        self.signature_key_pair.signature_scheme()
166    }
167
168    /// Get the ciphersuite
169    pub fn ciphersuite(&self) -> Ciphersuite {
170        self.ciphersuite
171    }
172
173    /// Generate a `CredentialWithKey`, which combines the credential type with the public portion of the keypair.
174    pub fn to_mls_credential_with_key(&self) -> CredentialWithKey {
175        CredentialWithKey {
176            credential: self.mls_credential.clone(),
177            signature_key: self.signature_key_pair.to_public_vec().into(),
178        }
179    }
180
181    /// Earliest valid time of creation for this credential.
182    ///
183    /// This is represented as seconds after the unix epoch.
184    ///
185    /// Only meaningful for X509, where it is the "valid_from" claim of the leaf credential.
186    /// For basic credentials, this is always 0 when the credential is first created.
187    /// It is updated upon being persisted to the database.
188    pub fn earliest_validity(&self) -> u64 {
189        self.earliest_validity
190    }
191
192    /// Get the client ID associated with this credential
193    pub fn client_id(&self) -> &ClientIdRef {
194        self.mls_credential.identity().into()
195    }
196}
197
198impl Credential {
199    /// Create a credential from an identifier
200    // currently only used in test code, but generally applicable
201    #[cfg_attr(not(test), expect(dead_code))]
202    pub(crate) fn from_identifier(
203        identifier: &ClientIdentifier,
204        ciphersuite: Ciphersuite,
205        crypto: impl OpenMlsCrypto,
206    ) -> Result<Self> {
207        match identifier {
208            ClientIdentifier::Basic(client_id) => Self::basic(ciphersuite, client_id.clone(), crypto),
209            ClientIdentifier::X509(certs) => {
210                let signature_scheme = ciphersuite.signature_algorithm();
211                let cert = certs
212                    .get(&signature_scheme)
213                    .ok_or(Error::SignatureSchemeNotPresentInX509Identity(signature_scheme))?;
214                Self::x509(ciphersuite, cert.clone())
215            }
216        }
217    }
218}
219
220impl From<Credential> for CredentialWithKey {
221    fn from(cb: Credential) -> Self {
222        Self {
223            credential: cb.mls_credential,
224            signature_key: cb.signature_key_pair.public().into(),
225        }
226    }
227}
228
229impl Eq for Credential {}
230impl PartialEq for Credential {
231    fn eq(&self, other: &Self) -> bool {
232        self.mls_credential == other.mls_credential && self.earliest_validity == other.earliest_validity && {
233            let sk = &self.signature_key_pair;
234            let ok = &other.signature_key_pair;
235            sk.signature_scheme() == ok.signature_scheme() && sk.public() == ok.public()
236            // public key equality implies private key equality
237        }
238    }
239}
240
241// TODO: ensure certificate signature must match the group's ciphersuite ; fails otherwise. Tracking issue: WPB-9632
242// Requires more than 1 ciphersuite supported at the moment.
243#[cfg(test)]
244mod tests {
245    use std::collections::HashMap;
246
247    use mls_crypto_provider::PkiKeypair;
248
249    use super::{x509::CertificateBundle, *};
250    use crate::{
251        ClientIdentifier, CredentialType, E2eiConversationState,
252        mls::{conversation::Conversation as _, credential::x509::CertificatePrivateKey},
253        test_utils::{
254            x509::{CertificateParams, X509TestChain},
255            *,
256        },
257    };
258
259    #[apply(all_cred_cipher)]
260    async fn basic_clients_can_send_messages(case: TestContext) {
261        if !case.is_basic() {
262            return;
263        }
264        let [alice, bob] = case.sessions_basic().await;
265        let conversation = case.create_conversation([&alice, &bob]).await;
266        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
267    }
268
269    #[apply(all_cred_cipher)]
270    async fn certificate_clients_can_send_messages(case: TestContext) {
271        if !case.is_x509() {
272            return;
273        }
274        let [alice, bob] = case.sessions_x509().await;
275        let conversation = case.create_conversation([&alice, &bob]).await;
276        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
277    }
278
279    #[apply(all_cred_cipher)]
280    async fn heterogeneous_clients_can_send_messages(case: TestContext) {
281        // check that both credentials can initiate/join a group
282        let ([x509_session], [basic_session]) = case.sessions_mixed_credential_types().await;
283        // That way the conversation creator (Alice) will have a different credential type than Bob
284        let (alice, bob, alice_credential_type) = match case.credential_type {
285            CredentialType::Basic => (x509_session, basic_session, CredentialType::X509),
286            CredentialType::X509 => (basic_session, x509_session, CredentialType::Basic),
287        };
288
289        let conversation = case
290            .create_heterogeneous_conversation(alice_credential_type, case.credential_type, [&alice, &bob])
291            .await;
292        assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
293    }
294
295    #[apply(all_cred_cipher)]
296    async fn should_fail_when_certificate_chain_is_empty(case: TestContext) {
297        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
298
299        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
300
301        let mut certs = CertificateBundle::rand(&"alice".into(), x509_intermediate);
302        certs.certificate_chain = vec![];
303        let alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), certs)]));
304
305        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
306            .await
307            .unwrap_err();
308        assert!(innermost_source_matches!(err, Error::InvalidIdentity));
309    }
310
311    #[apply(all_cred_cipher)]
312    async fn should_fail_when_certificate_chain_has_a_single_self_signed(case: TestContext) {
313        use crate::MlsErrorKind;
314
315        if !case.is_x509() {
316            return;
317        }
318        let mut x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
319
320        let (_alice_identifier, alice_cert) = x509_test_chain.issue_simple_certificate_bundle("alice", None);
321
322        let new_cert = alice_cert
323            .pki_keypair
324            .re_sign(&alice_cert.certificate, &alice_cert.certificate, None)
325            .unwrap();
326        let mut alice_cert = alice_cert.clone();
327        alice_cert.certificate = new_cert;
328        let cb = CertificateBundle::from_self_signed_certificate(&alice_cert);
329        let alice_identifier = ClientIdentifier::X509([(case.signature_scheme(), cb)].into());
330
331        let alice = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
332            .await
333            .unwrap();
334        let [bob] = case.sessions_x509().await;
335        let bob_key_package = bob.new_keypackage(&case).await;
336        let conversation = case.create_conversation([&alice]).await;
337        let err = conversation
338            .guard()
339            .await
340            .add_members([bob_key_package.into()].into())
341            .await
342            .unwrap_err();
343        assert!(innermost_source_matches!(err, MlsErrorKind::MlsAddMembersError(_)));
344    }
345
346    #[apply(all_cred_cipher)]
347    async fn should_fail_when_signature_key_doesnt_match_certificate_public_key(case: TestContext) {
348        if !case.is_x509() {
349            return;
350        }
351        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
352        let x509_intermediate = x509_test_chain.find_local_intermediate_ca();
353
354        let certs = CertificateBundle::rand(&"alice".into(), x509_intermediate);
355        let new_pki_kp = PkiKeypair::rand_unchecked(case.signature_scheme());
356
357        let eve_key = CertificatePrivateKey::new(new_pki_kp.signing_key_bytes());
358        let cb = CertificateBundle {
359            certificate_chain: certs.certificate_chain,
360            private_key: eve_key,
361            signature_scheme: case.ciphersuite().signature_algorithm(),
362        };
363        let alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), cb)]));
364
365        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
366            .await
367            .unwrap_err();
368        assert!(innermost_source_matches!(
369            err,
370            crate::MlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::MismatchKeypair),
371        ));
372    }
373
374    #[apply(all_cred_cipher)]
375    async fn should_not_fail_but_degrade_when_certificate_expired(case: TestContext) {
376        if !case.is_x509() {
377            return;
378        }
379        Box::pin(async move {
380            let mut x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
381
382            let expiration_time = core::time::Duration::from_secs(14);
383            let start = web_time::Instant::now();
384
385            let (alice_identifier, _) = x509_test_chain.issue_simple_certificate_bundle("alice", None);
386            let (bob_identifier, _) = x509_test_chain.issue_simple_certificate_bundle("bob", Some(expiration_time));
387            let alice = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
388                .await
389                .unwrap();
390            let bob = SessionContext::new_with_identifier(&case, bob_identifier, Some(&x509_test_chain))
391                .await
392                .unwrap();
393
394            let conversation = case.create_conversation([&alice, &bob]).await;
395            // this should work since the certificate is not yet expired
396            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
397
398            assert_eq!(
399                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
400                E2eiConversationState::Verified
401            );
402
403            let elapsed = start.elapsed();
404            // Give time to the certificate to expire
405            if expiration_time > elapsed {
406                smol::Timer::after(expiration_time - elapsed + core::time::Duration::from_secs(2)).await;
407            }
408
409            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
410            assert_eq!(
411                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
412                E2eiConversationState::NotVerified
413            );
414        })
415        .await;
416    }
417
418    #[apply(all_cred_cipher)]
419    async fn should_not_fail_but_degrade_when_basic_joins(case: TestContext) {
420        if !case.is_x509() {
421            return;
422        }
423        Box::pin(async {
424            let ([alice, bob], [charlie]) = case.sessions_mixed_credential_types().await;
425            let conversation = case.create_conversation([&alice, &bob]).await;
426
427            // this should work since the certificate is not yet expired
428            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
429            assert_eq!(
430                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
431                E2eiConversationState::Verified
432            );
433            assert_eq!(
434                conversation
435                    .guard_of(&bob)
436                    .await
437                    .e2ei_conversation_state()
438                    .await
439                    .unwrap(),
440                E2eiConversationState::Verified
441            );
442
443            // Charlie is a basic client that tries to join (i.e. emulates guest links in Wire)
444            let conversation = conversation
445                .invite_with_credential_type_notify(CredentialType::Basic, [&charlie])
446                .await;
447
448            assert_eq!(
449                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
450                E2eiConversationState::NotVerified
451            );
452            assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
453            assert_eq!(
454                conversation.guard().await.e2ei_conversation_state().await.unwrap(),
455                E2eiConversationState::NotVerified
456            );
457        })
458        .await;
459    }
460
461    #[apply(all_cred_cipher)]
462    async fn should_fail_when_certificate_not_valid_yet(case: TestContext) {
463        use crate::MlsErrorKind;
464
465        if !case.is_x509() {
466            return;
467        }
468        let x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
469
470        let tomorrow = now_std() + core::time::Duration::from_secs(3600 * 24);
471        let local_ca = x509_test_chain.find_local_intermediate_ca();
472        let alice_cert = {
473            let name = "alice";
474            let common_name = format!("{name} Smith");
475            let handle = format!("{}_wire", name.to_lowercase());
476            let client_id: String = crate::e2e_identity::id::QualifiedE2eiClientId::generate_with_domain("wire.com")
477                .try_into()
478                .unwrap();
479            local_ca.create_and_sign_end_identity(CertificateParams {
480                common_name: Some(common_name.clone()),
481                handle: Some(handle.clone()),
482                client_id: Some(client_id.clone()),
483                validity_start: Some(tomorrow),
484                ..Default::default()
485            })
486        };
487        let cb = CertificateBundle::from_certificate_and_issuer(&alice_cert, local_ca);
488        let alice_identifier = ClientIdentifier::X509(HashMap::from([(case.signature_scheme(), cb)]));
489        let err = SessionContext::new_with_identifier(&case, alice_identifier, Some(&x509_test_chain))
490            .await
491            .unwrap_err();
492
493        assert!(innermost_source_matches!(
494            err,
495            MlsErrorKind::MlsCryptoError(openmls::prelude::CryptoError::ExpiredCertificate),
496        ))
497    }
498
499    /// In order to be WASM-compatible
500    // pub fn now() -> wire_e2e_identity::prelude::OffsetDateTime {
501    //     let now_since_epoch = now_std().as_secs() as i64;
502    //     wire_e2e_identity::prelude::OffsetDateTime::from_unix_timestamp(now_since_epoch).unwrap()
503    // }
504    pub(crate) fn now_std() -> std::time::Duration {
505        let now = web_time::SystemTime::now();
506        now.duration_since(web_time::UNIX_EPOCH).unwrap()
507    }
508}