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;
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    /// Serializes both wrapped objects into TLS and return them as a tuple of byte arrays.
28    /// 0 -> welcome
29    /// 1 -> message
30    /// 2 -> public group state
31    #[allow(clippy::type_complexity)]
32    pub fn to_bytes_triple(self) -> Result<(Option<Vec<u8>>, Vec<u8>, MlsGroupInfoBundle)> {
33        use openmls::prelude::TlsSerializeTrait as _;
34        let welcome = self
35            .welcome
36            .as_ref()
37            .map(|w| {
38                w.tls_serialize_detached()
39                    .map_err(Error::tls_serialize("serialize welcome"))
40            })
41            .transpose()?;
42        let commit = self
43            .commit
44            .tls_serialize_detached()
45            .map_err(Error::tls_serialize("serialize commit"))?;
46        Ok((welcome, commit, self.group_info))
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use itertools::Itertools;
53    use openmls::prelude::SignaturePublicKey;
54
55    use super::{Error, *};
56    use crate::{
57        mls::conversation::{Conversation as _, ConversationWithMls as _},
58        test_utils::*,
59        transaction_context::Error as TransactionError,
60    };
61
62    mod transport {
63        use std::sync::Arc;
64
65        use super::*;
66
67        #[apply(all_cred_cipher)]
68        async fn retry_should_work(case: TestContext) {
69            let [alice, bob, charlie] = case.sessions().await;
70            Box::pin(async move {
71                // Create conversation
72                let conversation = case.create_conversation([&alice, &bob]).await;
73
74                // Bob produces a commit that Alice will receive only after she tried sending a commit
75                let commit = conversation.acting_as(&bob).await.update().await;
76                let bob_epoch = commit.conversation().guard_of(&bob).await.epoch().await;
77                assert_eq!(2, bob_epoch);
78                let alice_epoch = commit.conversation().guard_of(&alice).await.epoch().await;
79                assert_eq!(1, alice_epoch);
80                let intermediate_commit = commit.message();
81                // Next time a commit is sent, process the intermediate commit and return retry, success the second time
82                let retry_provider = Arc::new(
83                    CoreCryptoTransportRetrySuccessProvider::default().with_intermediate_commits(
84                        alice.clone(),
85                        &[intermediate_commit],
86                        commit.conversation().id(),
87                    ),
88                );
89
90                alice.replace_transport(retry_provider.clone()).await;
91
92                // Send two commits and process them on bobs side
93                // For this second commit, the retry provider will first return retry and
94                // then success, but now without an intermediate commit
95                let conversation = commit.finish().advance_epoch().await.invite_notify([&charlie]).await;
96
97                // Retry should have been returned twice
98                assert_eq!(retry_provider.retry_count().await, 2);
99                // Success should have been returned twice
100                assert_eq!(retry_provider.success_count().await, 2);
101
102                // Group is still in valid state
103                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
104            })
105            .await;
106        }
107    }
108
109    mod add_members {
110        use std::sync::Arc;
111
112        use super::*;
113
114        #[apply(all_cred_cipher)]
115        async fn can_add_members_to_conversation(case: TestContext) {
116            let [alice, bob] = case.sessions().await;
117            Box::pin(async move {
118                let conversation = case.create_conversation([&alice]).await;
119                let id = conversation.id.clone();
120                let bob_keypackage = bob.rand_key_package(&case).await;
121                // First, abort commit transport
122                alice
123                    .replace_transport(Arc::<CoreCryptoTransportAbortProvider>::default())
124                    .await;
125                alice
126                    .transaction
127                    .conversation(&id)
128                    .await
129                    .unwrap()
130                    .add_members(vec![bob_keypackage.clone()])
131                    .await
132                    .unwrap_err();
133
134                // commit is not applied
135                assert_eq!(conversation.member_count().await, 1);
136
137                alice
138                    .replace_transport(Arc::<CoreCryptoTransportSuccessProvider>::default())
139                    .await;
140
141                let conversation = conversation.invite_notify([&bob]).await;
142
143                assert_eq!(*conversation.id(), id);
144                assert_eq!(
145                    conversation
146                        .guard()
147                        .await
148                        .conversation()
149                        .await
150                        .group
151                        .group_id()
152                        .as_slice(),
153                    id.as_ref()
154                );
155                assert_eq!(conversation.member_count().await, 2);
156                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
157            })
158            .await
159        }
160
161        #[apply(all_cred_cipher)]
162        async fn should_return_valid_welcome(case: TestContext) {
163            let [alice, bob] = case.sessions().await;
164            Box::pin(async move {
165                let conversation = case.create_conversation([&alice, &bob]).await;
166                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
167            })
168            .await
169        }
170
171        #[apply(all_cred_cipher)]
172        async fn should_return_valid_group_info(case: TestContext) {
173            let [alice, bob, guest] = case.sessions().await;
174            Box::pin(async move {
175                let conversation = case.create_conversation([&alice, &bob]).await;
176                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
177                let group_info = commit_bundle.group_info.get_group_info();
178                let conversation = conversation
179                    .external_join_via_group_info_notify(&guest, group_info)
180                    .await;
181                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
182            })
183            .await
184        }
185    }
186
187    mod remove_members {
188        use super::*;
189
190        #[apply(all_cred_cipher)]
191        async fn alice_can_remove_bob_from_conversation(case: TestContext) {
192            let [alice, bob] = case.sessions().await;
193            Box::pin(async move {
194                let conversation = case.create_conversation([&alice, &bob]).await.remove_notify(&bob).await;
195                let id = conversation.id().clone();
196
197                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
198                assert!(welcome.is_none());
199
200                assert_eq!(conversation.member_count().await, 1);
201
202                // But has been removed from the conversation
203                assert!(matches!(
204                bob.transaction.conversation(&id).await.unwrap_err(),
205                TransactionError::Leaf(crate::LeafError::ConversationNotFound(ref i))
206                    if i == &id
207                ));
208                assert!(!conversation.can_talk(&alice, &bob).await);
209            })
210            .await;
211        }
212
213        #[apply(all_cred_cipher)]
214        async fn should_return_valid_welcome(case: TestContext) {
215            let [alice, bob, guest] = case.sessions().await;
216            Box::pin(async move {
217                let conversation = case
218                    .create_conversation([&alice, &bob])
219                    .await
220                    .invite_proposal_notify(&guest)
221                    .await
222                    .remove_notify(&bob)
223                    .await;
224
225                assert!(conversation.is_functional_and_contains([&alice, &guest]).await);
226                // because Bob has been removed from the group
227                assert!(!conversation.can_talk(&alice, &bob).await);
228            })
229            .await;
230        }
231
232        #[apply(all_cred_cipher)]
233        async fn should_return_valid_group_info(case: TestContext) {
234            let [alice, bob, guest] = case.sessions().await;
235            Box::pin(async move {
236                let conversation = case.create_conversation([&alice, &bob]).await.remove_notify(&bob).await;
237                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
238                let group_info = commit_bundle.group_info.get_group_info();
239                let conversation = conversation
240                    .external_join_via_group_info_notify(&guest, group_info)
241                    .await;
242
243                assert!(conversation.is_functional_and_contains([&alice, &guest]).await);
244                // because Bob has been removed from the group
245                assert!(!conversation.can_talk(&alice, &bob).await);
246            })
247            .await;
248        }
249    }
250
251    mod update_keying_material {
252        use super::*;
253
254        #[apply(all_cred_cipher)]
255        async fn should_succeed(case: TestContext) {
256            let [alice, bob] = case.sessions().await;
257            Box::pin(async move {
258                let conversation = case.create_conversation([&alice, &bob]).await;
259                let init_count = alice.transaction.count_entities().await;
260
261                let bob_keys = conversation
262                    .guard_of(&bob)
263                    .await
264                    .conversation()
265                    .await
266                    .encryption_keys()
267                    .collect::<Vec<Vec<u8>>>();
268                let alice_keys = conversation
269                    .guard()
270                    .await
271                    .conversation()
272                    .await
273                    .encryption_keys()
274                    .collect::<Vec<Vec<u8>>>();
275                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
276
277                let alice_key = conversation.encryption_public_key().await;
278
279                // proposing the key update for alice
280                let conversation = conversation.update_notify().await;
281                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
282                assert!(welcome.is_none());
283
284                let alice_new_keys = conversation
285                    .guard()
286                    .await
287                    .conversation()
288                    .await
289                    .encryption_keys()
290                    .collect::<Vec<Vec<u8>>>();
291                assert!(!alice_new_keys.contains(&alice_key));
292
293                let bob_new_keys = conversation
294                    .guard_of(&bob)
295                    .await
296                    .conversation()
297                    .await
298                    .encryption_keys()
299                    .collect::<Vec<Vec<u8>>>();
300                assert!(alice_new_keys.iter().all(|a_key| bob_new_keys.contains(a_key)));
301
302                // ensuring both can encrypt messages
303                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
304
305                // make sure inline update commit + merge does not leak anything
306                // that's obvious since no new encryption keypair is created in this case
307                let final_count = alice.transaction.count_entities().await;
308                assert_eq!(init_count, final_count);
309            })
310            .await;
311        }
312
313        #[apply(all_cred_cipher)]
314        async fn should_create_welcome_for_pending_add_proposals(case: TestContext) {
315            let [alice, bob, charlie] = case.sessions().await;
316            Box::pin(async move {
317                let conversation = case.create_conversation([&alice, &bob]).await;
318
319                let bob_keys = conversation
320                    .guard_of(&bob)
321                    .await
322                    .conversation()
323                    .await
324                    .signature_keys()
325                    .collect::<Vec<SignaturePublicKey>>();
326                let alice_keys = conversation
327                    .guard()
328                    .await
329                    .conversation()
330                    .await
331                    .signature_keys()
332                    .collect::<Vec<SignaturePublicKey>>();
333
334                // checking that the members on both sides are the same
335                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
336
337                let alice_key = conversation.encryption_public_key().await;
338
339                // proposing adding charlie
340                let conversation = conversation.invite_proposal_notify(&charlie).await;
341
342                assert!(
343                    conversation
344                        .guard()
345                        .await
346                        .conversation()
347                        .await
348                        .encryption_keys()
349                        .contains(&alice_key)
350                );
351
352                // The add proposal hasn't been committed yet
353                assert_eq!(conversation.member_count().await, 2);
354
355                // performing an update on Alice's key. this should generate a welcome for Charlie
356                let conversation = conversation.update_notify().await;
357                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
358                assert!(welcome.is_some());
359                assert!(
360                    !conversation
361                        .guard()
362                        .await
363                        .conversation()
364                        .await
365                        .encryption_keys()
366                        .contains(&alice_key)
367                );
368
369                assert_eq!(conversation.member_count().await, 3);
370
371                let alice_new_keys = conversation
372                    .guard()
373                    .await
374                    .conversation()
375                    .await
376                    .encryption_keys()
377                    .collect::<Vec<Vec<u8>>>();
378                assert!(!alice_new_keys.contains(&alice_key));
379
380                let bob_new_keys = conversation
381                    .guard_of(&bob)
382                    .await
383                    .conversation()
384                    .await
385                    .encryption_keys()
386                    .collect::<Vec<Vec<u8>>>();
387                assert!(alice_new_keys.iter().all(|a_key| bob_new_keys.contains(a_key)));
388
389                // ensure all parties can encrypt messages
390                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
391            })
392            .await;
393        }
394
395        #[apply(all_cred_cipher)]
396        async fn should_return_valid_welcome(case: TestContext) {
397            let [alice, bob, guest] = case.sessions().await;
398            Box::pin(async move {
399                let conversation = case
400                    .create_conversation([&alice, &bob])
401                    .await
402                    .invite_proposal_notify(&guest)
403                    .await
404                    .update_notify()
405                    .await;
406
407                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
408            })
409            .await;
410        }
411
412        #[apply(all_cred_cipher)]
413        async fn should_return_valid_group_info(case: TestContext) {
414            let [alice, bob, guest] = case.sessions().await;
415            Box::pin(async move {
416                let conversation = case.create_conversation([&alice, &bob]).await.update_notify().await;
417
418                let group_info = alice.mls_transport().await.latest_group_info().await;
419                let group_info = group_info.get_group_info();
420
421                let conversation = conversation
422                    .external_join_via_group_info_notify(&guest, group_info)
423                    .await;
424                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
425            })
426            .await;
427        }
428    }
429
430    mod commit_pending_proposals {
431        use super::*;
432
433        #[apply(all_cred_cipher)]
434        async fn should_create_a_commit_out_of_self_pending_proposals(case: TestContext) {
435            let [alice, bob] = case.sessions().await;
436            Box::pin(async move {
437                let conversation = case
438                    .create_conversation([&alice])
439                    .await
440                    .advance_epoch()
441                    .await
442                    .invite_proposal_notify(&bob)
443                    .await;
444
445                assert!(conversation.has_pending_proposals().await);
446                assert_eq!(conversation.member_count().await, 1);
447
448                let conversation = conversation.commit_pending_proposals_notify().await;
449                assert_eq!(conversation.member_count().await, 2);
450
451                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
452            })
453            .await;
454        }
455
456        #[apply(all_cred_cipher)]
457        async fn should_create_a_commit_out_of_pending_proposals_by_ref(case: TestContext) {
458            let [alice, bob, charlie] = case.sessions().await;
459            Box::pin(async move {
460                // Bob invites charlie
461                let conversation = case
462                    .create_conversation([&alice, &bob])
463                    .await
464                    .acting_as(&bob)
465                    .await
466                    .invite_proposal_notify(&charlie)
467                    .await
468                    .acting_as(&bob)
469                    .await;
470
471                assert!(conversation.has_pending_proposals().await);
472                assert_eq!(conversation.member_count().await, 2);
473
474                // Alice commits the proposal
475                let conversation = conversation.commit_pending_proposals_notify().await;
476                assert_eq!(conversation.member_count().await, 3);
477
478                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
479            })
480            .await;
481        }
482
483        #[apply(all_cred_cipher)]
484        async fn should_return_valid_welcome(case: TestContext) {
485            let [alice, bob] = case.sessions().await;
486            Box::pin(async move {
487                let conversation = case
488                    .create_conversation([&alice])
489                    .await
490                    .invite_proposal_notify(&bob)
491                    .await
492                    .commit_pending_proposals_notify()
493                    .await;
494
495                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
496            })
497            .await;
498        }
499
500        #[apply(all_cred_cipher)]
501        async fn should_return_valid_group_info(case: TestContext) {
502            let [alice, bob, guest] = case.sessions().await;
503            Box::pin(async move {
504                let conversation = case
505                    .create_conversation([&alice])
506                    .await
507                    .invite_proposal_notify(&bob)
508                    .await
509                    .commit_pending_proposals_notify()
510                    .await;
511                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
512                let group_info = commit_bundle.group_info.get_group_info();
513                let conversation = conversation
514                    .external_join_via_group_info_notify(&guest, group_info)
515                    .await;
516
517                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
518            })
519            .await;
520        }
521    }
522
523    mod delivery_semantics {
524        use super::*;
525
526        #[apply(all_cred_cipher)]
527        async fn should_prevent_out_of_order_commits(case: TestContext) {
528            let [alice, bob] = case.sessions().await;
529            Box::pin(async move {
530                let conversation = case.create_conversation([&alice, &bob]).await;
531                let id = conversation.id().clone();
532
533                let commit_guard = conversation.update().await;
534                let commit1 = commit_guard.message();
535                let commit1 = commit1.to_bytes().unwrap();
536
537                let commit_guard = commit_guard.finish().update().await;
538                let commit2 = commit_guard.message();
539                let commit2 = commit2.to_bytes().unwrap();
540
541                // fails when a commit is skipped
542                let out_of_order = bob
543                    .transaction
544                    .conversation(&id)
545                    .await
546                    .unwrap()
547                    .decrypt_message(&commit2)
548                    .await;
549                assert!(matches!(out_of_order.unwrap_err(), Error::BufferedFutureMessage { .. }));
550
551                // works in the right order though
552                // NB: here 'commit2' has been buffered so it is also applied when we decrypt commit1
553                bob.transaction
554                    .conversation(&id)
555                    .await
556                    .unwrap()
557                    .decrypt_message(&commit1)
558                    .await
559                    .unwrap();
560
561                // and then fails again when trying to decrypt a commit with an epoch in the past
562                let past_commit = bob
563                    .transaction
564                    .conversation(&id)
565                    .await
566                    .unwrap()
567                    .decrypt_message(&commit1)
568                    .await;
569                assert!(matches!(past_commit.unwrap_err(), Error::StaleCommit));
570            })
571            .await;
572        }
573
574        #[apply(all_cred_cipher)]
575        async fn should_prevent_replayed_encrypted_handshake_messages(case: TestContext) {
576            if !case.is_pure_ciphertext() {
577                return;
578            }
579
580            let [alice, bob] = case.sessions().await;
581            Box::pin(async move {
582                let conversation = case.create_conversation([&alice, &bob]).await;
583
584                let proposal_guard = conversation.update_proposal().await;
585                let proposal_replay = proposal_guard.message();
586
587                // replayed encrypted proposal should fail
588                let conversation = proposal_guard.notify_members().await;
589                assert!(matches!(
590                    conversation
591                        .guard_of(&bob)
592                        .await
593                        .decrypt_message(proposal_replay.to_bytes().unwrap())
594                        .await
595                        .unwrap_err(),
596                    Error::DuplicateMessage
597                ));
598
599                let commit_guard = conversation.update().await;
600                let commit_replay = commit_guard.message();
601
602                // replayed encrypted commit should fail
603                let conversation = commit_guard.notify_members().await;
604                assert!(matches!(
605                    conversation
606                        .guard_of(&bob)
607                        .await
608                        .decrypt_message(commit_replay.to_bytes().unwrap())
609                        .await
610                        .unwrap_err(),
611                    Error::StaleCommit
612                ));
613            })
614            .await;
615        }
616    }
617}