Skip to main content

core_crypto/mls/conversation/
commit.rs

1//! This table summarizes when a MLS group can create a commit or proposal:
2//!
3//! | can create handshake ? | 0 pend. Commit | 1 pend. Commit |
4//! |------------------------|----------------|----------------|
5//! | 0 pend. Proposal       | ✅              | ❌              |
6//! | 1+ pend. Proposal      | ✅              | ❌              |
7
8use openmls::prelude::MlsMessageOut;
9
10use super::{Error, Result};
11use crate::{GroupInfoBundle, mls::conversation::WelcomeMessage};
12
13/// Returned when a commit is created
14#[derive(Debug, Clone)]
15pub struct CommitBundle {
16    /// A welcome message if there are pending Add proposals
17    pub welcome: Option<MlsMessageOut>,
18    /// The commit message
19    pub commit: MlsMessageOut,
20    /// `GroupInfo` if the commit is merged
21    pub group_info: GroupInfoBundle,
22    /// An encrypted message to fan out to all other conversation members in the new epoch
23    pub encrypted_message: Option<Vec<u8>>,
24}
25
26impl CommitBundle {
27    /// Unpacks this struct and serializes the commit
28    ///
29    /// 0 -> welcome
30    /// 1 -> message
31    /// 2 -> public group state
32    #[allow(clippy::type_complexity)]
33    pub fn to_bytes_triple(self) -> Result<(Option<WelcomeMessage>, Vec<u8>, GroupInfoBundle)> {
34        use openmls::prelude::TlsSerializeTrait as _;
35        let welcome = self.welcome.map(Into::into);
36        let commit = self
37            .commit
38            .tls_serialize_detached()
39            .map_err(Error::tls_serialize("serialize commit"))?;
40        Ok((welcome, commit, self.group_info))
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::{Error, *};
47    use crate::{test_utils::*, transaction_context::Error as TransactionError};
48
49    mod add_members {
50        use std::sync::Arc;
51
52        use super::*;
53        use crate::Credential;
54
55        #[apply(all_cred_cipher)]
56        async fn can_add_members_to_conversation(case: TestContext) {
57            let [alice, bob] = case.sessions().await;
58            Box::pin(async move {
59                let conversation = case.create_conversation([&alice]).await;
60                let id = conversation.id.clone();
61                let bob_keypackage = bob.new_keypackage(&case).await;
62                // First, abort commit transport
63                alice
64                    .replace_transport(Arc::<CoreCryptoTransportAbortProvider>::default())
65                    .await;
66                alice
67                    .transaction
68                    .conversation(&id)
69                    .await
70                    .unwrap()
71                    .add_members(vec![bob_keypackage.clone().into()])
72                    .await
73                    .unwrap_err();
74
75                // commit is not applied
76                assert_eq!(conversation.member_count().await, 1);
77
78                alice
79                    .replace_transport(Arc::<CoreCryptoTransportSuccessProvider>::default())
80                    .await;
81
82                let conversation = conversation.invite_notify([&bob]).await;
83
84                assert_eq!(*conversation.id(), id);
85                assert_eq!(
86                    conversation.guard().await.group().await.group_id().as_slice(),
87                    id.as_ref()
88                );
89                assert_eq!(conversation.member_count().await, 2);
90                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
91            })
92            .await
93        }
94
95        #[apply(all_cred_cipher)]
96        async fn should_fail_on_duplicate_signatures(case: TestContext) {
97            let [alice, bob, carol] = case.sessions().await;
98            Box::pin(async move {
99                let conversation = case.create_conversation([&alice]).await;
100                let id = conversation.id.clone();
101                let bob_keypackage = bob.new_keypackage(&case).await;
102                let signature_key_pair = bob
103                    .find_any_credential(case.cipher_suite(), case.credential_type)
104                    .await
105                    .signature_key_pair
106                    .clone();
107                let credential = Credential {
108                    cipher_suite: case.cipher_suite(),
109                    credential_type: CredentialType::Basic,
110                    mls_credential: openmls::credentials::Credential::new_basic(
111                        carol.get_client_id().await.into_inner(),
112                    ),
113                    signature_key_pair,
114                    earliest_validity: 0,
115                };
116                let cred_ref = carol.add_credential(credential).await.unwrap();
117                let carol_key_package = carol.new_keypackage_from_ref(cred_ref, None).await;
118                let _affected_clients = [(carol.get_client_id().await, bob.get_client_id().await)];
119
120                let error = alice
121                    .transaction
122                    .conversation(&id)
123                    .await
124                    .unwrap()
125                    .add_members(vec![bob_keypackage.clone().into(), carol_key_package.clone().into()])
126                    .await
127                    .unwrap_err();
128
129                assert!(matches!(
130                    error,
131                    Error::DuplicateSignature {
132                        affected_clients: _affected_clients
133                    }
134                ));
135            })
136            .await
137        }
138
139        #[apply(all_cred_cipher)]
140        async fn should_return_valid_welcome(case: TestContext) {
141            let [alice, bob] = case.sessions().await;
142            Box::pin(async move {
143                let conversation = case.create_conversation([&alice, &bob]).await;
144                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
145            })
146            .await
147        }
148
149        #[apply(all_cred_cipher)]
150        async fn should_return_valid_group_info(case: TestContext) {
151            let [alice, bob, guest] = case.sessions().await;
152            Box::pin(async move {
153                let conversation = case.create_conversation([&alice, &bob]).await;
154                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
155                let group_info = commit_bundle.group_info.get_group_info();
156                let conversation = conversation
157                    .external_join_via_group_info_notify(&guest, group_info)
158                    .await;
159                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
160            })
161            .await
162        }
163    }
164
165    mod remove_members {
166        use super::*;
167
168        #[apply(all_cred_cipher)]
169        async fn alice_can_remove_bob_from_conversation(case: TestContext) {
170            let [alice, bob] = case.sessions().await;
171            Box::pin(async move {
172                let conversation = case.create_conversation([&alice, &bob]).await.remove_notify(&bob).await;
173                let id = conversation.id().clone();
174
175                let CommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
176                assert!(welcome.is_none());
177
178                assert_eq!(conversation.member_count().await, 1);
179
180                // But has been removed from the conversation
181                assert!(matches!(
182                bob.transaction.conversation(&id).await.unwrap_err(),
183                TransactionError::Leaf(crate::LeafError::ConversationNotFound(ref i))
184                    if i == &id
185                ));
186                assert!(!conversation.can_talk(&alice, &bob).await);
187            })
188            .await;
189        }
190
191        #[apply(all_cred_cipher)]
192        async fn should_return_valid_group_info(case: TestContext) {
193            let [alice, bob, guest] = case.sessions().await;
194            Box::pin(async move {
195                let conversation = case.create_conversation([&alice, &bob]).await.remove_notify(&bob).await;
196                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
197                let group_info = commit_bundle.group_info.get_group_info();
198                let conversation = conversation
199                    .external_join_via_group_info_notify(&guest, group_info)
200                    .await;
201
202                assert!(conversation.is_functional_and_contains([&alice, &guest]).await);
203                // because Bob has been removed from the group
204                assert!(!conversation.can_talk(&alice, &bob).await);
205            })
206            .await;
207        }
208    }
209
210    mod update_keying_material {
211        use super::*;
212
213        #[apply(all_cred_cipher)]
214        async fn should_succeed(case: TestContext) {
215            let [alice, bob] = case.sessions().await;
216            Box::pin(async move {
217                let conversation = case.create_conversation([&alice, &bob]).await;
218                let init_count = alice.transaction.count_entities().await;
219
220                let bob_keys = conversation.guard_of(&bob).await.encryption_keys().await;
221                let alice_keys = conversation.guard().await.encryption_keys().await;
222                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
223
224                let alice_key = conversation.encryption_public_key().await;
225
226                // proposing the key update for alice
227                let conversation = conversation.update_notify().await;
228                let CommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
229                assert!(welcome.is_none());
230
231                let alice_new_keys = conversation.guard().await.encryption_keys().await;
232                assert!(!alice_new_keys.contains(&alice_key));
233
234                let bob_new_keys = conversation.guard_of(&bob).await.encryption_keys().await;
235                assert!(alice_new_keys.iter().all(|a_key| bob_new_keys.contains(a_key)));
236
237                // ensuring both can encrypt messages
238                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
239
240                // make sure inline update commit + merge does not leak anything
241                // that's obvious since no new encryption keypair is created in this case
242                let final_count = alice.transaction.count_entities().await;
243                assert_eq!(init_count, final_count);
244            })
245            .await;
246        }
247
248        #[apply(all_cred_cipher)]
249        async fn should_return_valid_group_info(case: TestContext) {
250            let [alice, bob, guest] = case.sessions().await;
251            Box::pin(async move {
252                let conversation = case.create_conversation([&alice, &bob]).await.update_notify().await;
253
254                let group_info = alice.mls_transport().await.latest_group_info().await;
255                let group_info = group_info.get_group_info();
256
257                let conversation = conversation
258                    .external_join_via_group_info_notify(&guest, group_info)
259                    .await;
260                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
261            })
262            .await;
263        }
264    }
265
266    mod commit_pending_proposals {
267        use super::*;
268
269        #[apply(all_cred_cipher)]
270        async fn should_create_a_commit_out_of_pending_proposals_by_ref(case: TestContext) {
271            let [alice, bob, charlie] = case.sessions().await;
272            Box::pin(async move {
273                // Bob removes charlie
274                let conversation = case
275                    .create_conversation([&alice, &bob, &charlie])
276                    .await
277                    .acting_as(&bob)
278                    .await
279                    .remove_proposal_notify(&charlie)
280                    .await
281                    .acting_as(&bob)
282                    .await;
283
284                assert!(conversation.has_pending_proposals().await);
285                assert_eq!(conversation.member_count().await, 3);
286
287                // Alice commits the proposal
288                let conversation = conversation.commit_pending_proposals_notify().await;
289                assert_eq!(conversation.member_count().await, 2);
290
291                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
292            })
293            .await;
294        }
295    }
296
297    mod delivery_semantics {
298        use super::*;
299
300        #[apply(all_cred_cipher)]
301        async fn should_prevent_out_of_order_commits(case: TestContext) {
302            let [alice, bob] = case.sessions().await;
303            Box::pin(async move {
304                let conversation = case.create_conversation([&alice, &bob]).await;
305                let id = conversation.id().clone();
306
307                let commit_guard = conversation.update().await;
308                let commit1 = commit_guard.message();
309                let commit1 = commit1.to_bytes().unwrap();
310
311                let commit_guard = commit_guard.finish().update().await;
312                let commit2 = commit_guard.message();
313                let commit2 = commit2.to_bytes().unwrap();
314
315                // fails when a commit is skipped
316                let out_of_order = bob
317                    .transaction
318                    .conversation(&id)
319                    .await
320                    .unwrap()
321                    .decrypt_message(&commit2)
322                    .await;
323                assert!(matches!(out_of_order.unwrap_err(), Error::BufferedFutureMessage { .. }));
324
325                // works in the right order though
326                // NB: here 'commit2' has been buffered so it is also applied when we decrypt commit1
327                bob.transaction
328                    .conversation(&id)
329                    .await
330                    .unwrap()
331                    .decrypt_message(&commit1)
332                    .await
333                    .unwrap();
334
335                // and then fails again when trying to decrypt a commit with an epoch in the past
336                let past_commit = bob
337                    .transaction
338                    .conversation(&id)
339                    .await
340                    .unwrap()
341                    .decrypt_message(&commit1)
342                    .await;
343                assert!(matches!(past_commit.unwrap_err(), Error::StaleCommit));
344            })
345            .await;
346        }
347
348        #[apply(all_cred_cipher)]
349        async fn should_prevent_replayed_encrypted_handshake_messages(case: TestContext) {
350            if !case.is_pure_ciphertext() {
351                return;
352            }
353
354            let [alice, bob] = case.sessions().await;
355            Box::pin(async move {
356                let conversation = case.create_conversation([&alice, &bob]).await;
357
358                let commit_guard = conversation.update().await;
359                let commit_replay = commit_guard.message();
360
361                // replayed encrypted commit should fail
362                let conversation = commit_guard.notify_members().await;
363                assert!(matches!(
364                    conversation
365                        .guard_of(&bob)
366                        .await
367                        .decrypt_message(commit_replay.to_bytes().unwrap())
368                        .await
369                        .unwrap_err(),
370                    Error::StaleCommit
371                ));
372            })
373            .await;
374        }
375    }
376}