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