core_crypto/
ephemeral.rs

1//! Utilities for ephemeral CoreCrypto instances.
2//!
3//! Ephemeral instances are intended to support history sharing. History sharing works like this:
4//! every history-enabled conversation has a passive "history client" as a member. This client
5//! is a member of the MLS group (and can therefore decrypt messages), but it is not actively running
6//! on any device or decrypting any messages.
7//!
8//! Approximately once daily, and whenever a member is removed from the group, a new history-sharing era
9//! begins. The client submitting the commit which instantiates the new history-sharing era is responsible
10//! for ensuring that the old history client is removed from the group, and new one is added. Additionally,
11//! one of the first application messages in the new history-sharing era contains the serialized history
12//! secret.
13//!
14//! When a new client joins the history-enabled conversation, they receive a list of history secrets
15//! and their associated history-sharing eras (identified by the epoch number at which they start).
16//! For each history-sharing era, they can instantiate an ephemeral client from the history secret,
17//! and use that client to decrypt all messages in this era.
18//!
19//! Though ephemeral clients are full instances of `CoreCrypto` and contain the same API, they should
20//! not be used to generate messages for sending. They should also not be instantiated to follow along with
21//! new messages as they are received, as that's pointless; the individual credentials suffice.
22
23use std::collections::HashSet;
24
25use mls_crypto_provider::DatabaseKey;
26use openmls::prelude::{KeyPackageSecretEncapsulation, SignatureScheme};
27
28use crate::{
29    CoreCrypto, Error, MlsError, RecursiveError, Result,
30    mls::credential::CredentialBundle,
31    prelude::{ClientId, ClientIdentifier, MlsCiphersuite, MlsClientConfiguration, MlsCredentialType, Session},
32};
33
34/// We always instantiate history clients with this prefix in their client id, so
35/// we can use prefix testing to determine with some accuracy whether or not something is a history client.
36const HISTORY_CLIENT_ID_PREFIX: &str = "history-client";
37
38/// A `HistorySecret` encodes sufficient client state that it can be used to instantiate an
39/// ephemeral client.
40#[derive(serde::Serialize, serde::Deserialize)]
41pub struct HistorySecret {
42    pub(crate) client_id: ClientId,
43    pub(crate) credential_bundle: CredentialBundle,
44    pub(crate) key_package: KeyPackageSecretEncapsulation,
45}
46
47/// Create a new [`CoreCrypto`] with an **uninitialized** mls session.
48///
49/// You must initialize the session yourself before using this!
50async fn in_memory_cc_with_ciphersuite(ciphersuite: impl Into<MlsCiphersuite>) -> Result<CoreCrypto> {
51    let ciphersuites = vec![ciphersuite.into()];
52
53    let configuration = MlsClientConfiguration {
54        // we know what ciphersuite we want, at least
55        ciphersuites: ciphersuites.clone(),
56        // we have the client id from the history secret, but we don't want to use it here because
57        // that kicks off the `init`, and we want to inject our secret keys into the keystore before then
58        client_id: None,
59        // not used in in-memory client
60        store_path: String::new(),
61        // important so our keys aren't memory-snooped, but its actual value is irrelevant
62        database_key: DatabaseKey::generate(),
63        // irrelevant for this case
64        external_entropy: None,
65        // don't generate any keypackages; we do not want to ever add this client to a different group
66        nb_init_key_packages: Some(0),
67    };
68
69    // Construct the MLS session, but don't initialize it. The implementation when `client_id` is `None` just
70    // does construction, which is what we need.
71    let session = Session::try_new_in_memory(configuration)
72        .await
73        .map_err(RecursiveError::mls("creating ephemeral session"))?;
74
75    Ok(session.into())
76}
77
78/// Generate a new [`HistorySecret`].
79///
80/// This is useful when it's this client's turn to generate a new history client.
81///
82/// The generated secret is cryptographically unrelated to the current CoreCrypto client.
83///
84/// Note that this is a crate-private function; the public interface for this feature is
85/// [`Conversation::generate_history_secret`][core_crypto::mls::conversation::Conversation::generate_history_secret].
86/// This implementation lives here instead of there for organizational reasons.
87pub(crate) async fn generate_history_secret(ciphersuite: MlsCiphersuite) -> Result<HistorySecret> {
88    // generate a new completely arbitrary client id
89    let client_id = uuid::Uuid::new_v4();
90    let client_id = format!("{HISTORY_CLIENT_ID_PREFIX}-{client_id}");
91    let client_id = ClientId::from(client_id.into_bytes());
92    let identifier = ClientIdentifier::Basic(client_id);
93
94    let cc = in_memory_cc_with_ciphersuite(ciphersuite).await?;
95    let tx = cc
96        .new_transaction()
97        .await
98        .map_err(RecursiveError::transaction("creating new transaction"))?;
99    cc.init(identifier.clone(), &[ciphersuite], &cc.crypto_provider, 0)
100        .await
101        .map_err(RecursiveError::mls_client("initializing ephemeral cc"))?;
102
103    // we can get a credential bundle from a provider and ciphersuite
104    let mut signature_schemes = HashSet::with_capacity(1);
105    signature_schemes.insert(SignatureScheme::from(ciphersuite.0));
106    let bundles = identifier
107        .generate_credential_bundles(&cc.crypto_provider, signature_schemes)
108        .map_err(RecursiveError::mls_client("generating credential bundles"))?;
109    let [(_signature_scheme, client_id, credential_bundle)] = bundles
110        .try_into()
111        .expect("given exactly 1 signature scheme we must get exactly 1 credential bundle");
112
113    // we can generate a key package from the ephemeral cc and ciphersutite
114    let [key_package] = tx
115        .get_or_create_client_keypackages(ciphersuite, MlsCredentialType::Basic, 1)
116        .await
117        .map_err(RecursiveError::transaction("generating keypackages"))?
118        .try_into()
119        .expect("generating 1 keypackage returns 1 keypackage");
120    let key_package = KeyPackageSecretEncapsulation::load(&cc.crypto_provider, key_package)
121        .await
122        .map_err(MlsError::wrap("encapsulating key package"))?;
123
124    // we don't need to finish the transaction here--the point of the ephemeral CC was that no mutations would be saved there
125
126    Ok(HistorySecret {
127        client_id,
128        credential_bundle,
129        key_package,
130    })
131}
132
133impl CoreCrypto {
134    /// Instantiate a history client.
135    ///
136    /// This client exposes the full interface of `CoreCrypto`, but it should only be used to decrypt messages.
137    /// Other use is a logic error.
138    pub async fn history_client(history_secret: HistorySecret) -> Result<Self> {
139        if !history_secret
140            .client_id
141            .starts_with(HISTORY_CLIENT_ID_PREFIX.as_bytes())
142        {
143            return Err(Error::InvalidHistorySecret("client id has invalid format"));
144        }
145
146        let session = in_memory_cc_with_ciphersuite(history_secret.key_package.ciphersuite()).await?;
147        let tx = session
148            .new_transaction()
149            .await
150            .map_err(RecursiveError::transaction("creating new transaction"))?;
151
152        session
153            .restore_from_history_secret(history_secret)
154            .await
155            .map_err(RecursiveError::mls_client(
156                "restoring ephemeral session from history secret",
157            ))?;
158
159        tx.finish()
160            .await
161            .map_err(RecursiveError::transaction("finishing transaction"))?;
162
163        Ok(session)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use rstest::rstest;
170    use rstest_reuse::apply;
171    use wasm_bindgen_test::wasm_bindgen_test;
172
173    use crate::test_utils::{SessionContext, TestContext, all_cred_cipher, conversation_id};
174
175    use super::*;
176
177    /// Create a history secret, and restore it into a CoreCrypto instance
178    #[apply(all_cred_cipher)]
179    #[wasm_bindgen_test]
180    async fn can_create_ephemeral_client(case: TestContext) {
181        if case.credential_type != MlsCredentialType::Basic {
182            // history client will only ever have basic credentials, so not much point in testing
183            // how it interacts with an x509-only conversation
184            return;
185        }
186
187        use crate::mls::conversation::Conversation as _;
188
189        let [alice] = case.sessions().await;
190        let id = conversation_id();
191        alice
192            .transaction
193            .new_conversation(&id, case.credential_type, case.cfg.clone())
194            .await
195            .unwrap();
196
197        let conversation = alice.session.get_raw_conversation(&id).await.unwrap();
198        let history_secret = conversation.generate_history_secret().await.unwrap();
199
200        // the history secret has to survive encoding and decoding into some arbitrary serde format,
201        // so round-trip it
202        // note: we're not testing the serialization format
203        let encoded = rmp_serde::to_vec(&history_secret).unwrap();
204        let history_secret = rmp_serde::from_slice::<HistorySecret>(&encoded).unwrap();
205
206        let ephemeral_client = CoreCrypto::history_client(history_secret).await.unwrap();
207
208        // so how can we test that this has actually worked, given that we have not yet implemented the
209        // bit where we can actually enable history for a conversation, adding a history client? Well,
210        // with the caveat that
211        // WE SHOULD NOT DO THIS OUTSIDE A TESTING CONTEXT
212        // , we may as well try to
213        // roundtrip a conversation with Alice; that should at least prove that the ephemeral client
214        // has the basic minimal set of data in its keystore set properly.
215        let ephemeral_identifier = ClientIdentifier::Basic(ephemeral_client.mls.id().await.unwrap());
216        let ephemeral_session_context = SessionContext::new_with_identifier(&case, ephemeral_identifier, None)
217            .await
218            .unwrap();
219
220        alice
221            .invite_all(&case, &id, [&ephemeral_session_context])
222            .await
223            .unwrap();
224
225        assert!(ephemeral_session_context.try_talk_to(&id, &alice).await.is_ok());
226    }
227
228    #[apply(all_cred_cipher)]
229    #[wasm_bindgen_test]
230    async fn ephemeral_client_can_receive_messages_from_x509(case: TestContext) {
231        if case.credential_type != MlsCredentialType::X509 {
232            // the only point of this test is to ensure the history client can receive messages from X509
233            return;
234        }
235
236        use crate::{mls::conversation::Conversation as _, test_utils::x509::X509TestChain};
237
238        // set up alice with x509
239        let mut x509_test_chain = X509TestChain::init_empty(case.signature_scheme());
240        let (alice_identifier, _) = x509_test_chain.issue_simple_certificate_bundle("alice", None);
241        let alice = SessionContext::new_with_identifier(&case, alice_identifier, Some((&x509_test_chain).into()))
242            .await
243            .unwrap();
244
245        let id = conversation_id();
246        alice
247            .transaction
248            .new_conversation(&id, case.credential_type, case.cfg.clone())
249            .await
250            .unwrap();
251
252        // set up a history client for this conversation
253        let conversation = alice.session.get_raw_conversation(&id).await.unwrap();
254        let history_secret = conversation.generate_history_secret().await.unwrap();
255        let ephemeral_client = CoreCrypto::history_client(history_secret).await.unwrap();
256
257        let ephemeral_identifier = ClientIdentifier::Basic(ephemeral_client.mls.id().await.unwrap());
258        let ephemeral_session_context = SessionContext::new_with_identifier(&case, ephemeral_identifier, None)
259            .await
260            .unwrap();
261
262        // can the history client decrypt messages from alice? let's find out
263        let eph_kp = ephemeral_session_context
264            .rand_key_package_of_type(&case, MlsCredentialType::Basic)
265            .await;
266        alice
267            .invite_all_members(&case, &id, [(&ephemeral_session_context, eph_kp)])
268            .await
269            .unwrap();
270
271        assert!(alice.try_talk_to(&id, &ephemeral_session_context).await.is_ok());
272    }
273}