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