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        use crate::Credential;
114
115        #[apply(all_cred_cipher)]
116        async fn can_add_members_to_conversation(case: TestContext) {
117            let [alice, bob] = case.sessions().await;
118            Box::pin(async move {
119                let conversation = case.create_conversation([&alice]).await;
120                let id = conversation.id.clone();
121                let bob_keypackage = bob.new_keypackage(&case).await;
122                // First, abort commit transport
123                alice
124                    .replace_transport(Arc::<CoreCryptoTransportAbortProvider>::default())
125                    .await;
126                alice
127                    .transaction
128                    .conversation(&id)
129                    .await
130                    .unwrap()
131                    .add_members(vec![bob_keypackage.clone().into()])
132                    .await
133                    .unwrap_err();
134
135                // commit is not applied
136                assert_eq!(conversation.member_count().await, 1);
137
138                alice
139                    .replace_transport(Arc::<CoreCryptoTransportSuccessProvider>::default())
140                    .await;
141
142                let conversation = conversation.invite_notify([&bob]).await;
143
144                assert_eq!(*conversation.id(), id);
145                assert_eq!(
146                    conversation
147                        .guard()
148                        .await
149                        .conversation()
150                        .await
151                        .group
152                        .group_id()
153                        .as_slice(),
154                    id.as_ref()
155                );
156                assert_eq!(conversation.member_count().await, 2);
157                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
158            })
159            .await
160        }
161
162        #[apply(all_cred_cipher)]
163        async fn should_fail_on_duplicate_signatures(case: TestContext) {
164            let [alice, bob, carol] = case.sessions().await;
165            Box::pin(async move {
166                let conversation = case.create_conversation([&alice]).await;
167                let id = conversation.id.clone();
168                let bob_keypackage = bob.new_keypackage(&case).await;
169                let signature_key_pair = bob
170                    .find_any_credential(case.ciphersuite(), case.credential_type)
171                    .await
172                    .signature_key_pair
173                    .clone();
174                let credential = Credential {
175                    ciphersuite: case.ciphersuite(),
176                    credential_type: CredentialType::Basic,
177                    mls_credential: openmls::credentials::Credential::new_basic(
178                        carol.get_client_id().await.into_inner(),
179                    ),
180                    signature_key_pair,
181                    earliest_validity: 0,
182                };
183                let cred_ref = carol.add_credential(credential).await.unwrap();
184                let carol_key_package = carol.new_keypackage_from_ref(cred_ref, None).await;
185                let _affected_clients = [(carol.get_client_id().await, bob.get_client_id().await)];
186
187                let error = alice
188                    .transaction
189                    .conversation(&id)
190                    .await
191                    .unwrap()
192                    .add_members(vec![bob_keypackage.clone().into(), carol_key_package.clone().into()])
193                    .await
194                    .unwrap_err();
195
196                assert!(matches!(
197                    error,
198                    Error::DuplicateSignature {
199                        affected_clients: _affected_clients
200                    }
201                ));
202            })
203            .await
204        }
205
206        #[apply(all_cred_cipher)]
207        async fn should_return_valid_welcome(case: TestContext) {
208            let [alice, bob] = case.sessions().await;
209            Box::pin(async move {
210                let conversation = case.create_conversation([&alice, &bob]).await;
211                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
212            })
213            .await
214        }
215
216        #[apply(all_cred_cipher)]
217        async fn should_return_valid_group_info(case: TestContext) {
218            let [alice, bob, guest] = case.sessions().await;
219            Box::pin(async move {
220                let conversation = case.create_conversation([&alice, &bob]).await;
221                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
222                let group_info = commit_bundle.group_info.get_group_info();
223                let conversation = conversation
224                    .external_join_via_group_info_notify(&guest, group_info)
225                    .await;
226                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
227            })
228            .await
229        }
230    }
231
232    mod remove_members {
233        use super::*;
234
235        #[apply(all_cred_cipher)]
236        async fn alice_can_remove_bob_from_conversation(case: TestContext) {
237            let [alice, bob] = case.sessions().await;
238            Box::pin(async move {
239                let conversation = case.create_conversation([&alice, &bob]).await.remove_notify(&bob).await;
240                let id = conversation.id().clone();
241
242                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
243                assert!(welcome.is_none());
244
245                assert_eq!(conversation.member_count().await, 1);
246
247                // But has been removed from the conversation
248                assert!(matches!(
249                bob.transaction.conversation(&id).await.unwrap_err(),
250                TransactionError::Leaf(crate::LeafError::ConversationNotFound(ref i))
251                    if i == &id
252                ));
253                assert!(!conversation.can_talk(&alice, &bob).await);
254            })
255            .await;
256        }
257
258        #[apply(all_cred_cipher)]
259        async fn should_return_valid_welcome(case: TestContext) {
260            let [alice, bob, guest] = case.sessions().await;
261            Box::pin(async move {
262                let conversation = case
263                    .create_conversation([&alice, &bob])
264                    .await
265                    .invite_proposal_notify(&guest)
266                    .await
267                    .remove_notify(&bob)
268                    .await;
269
270                assert!(conversation.is_functional_and_contains([&alice, &guest]).await);
271                // because Bob has been removed from the group
272                assert!(!conversation.can_talk(&alice, &bob).await);
273            })
274            .await;
275        }
276
277        #[apply(all_cred_cipher)]
278        async fn should_return_valid_group_info(case: TestContext) {
279            let [alice, bob, guest] = case.sessions().await;
280            Box::pin(async move {
281                let conversation = case.create_conversation([&alice, &bob]).await.remove_notify(&bob).await;
282                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
283                let group_info = commit_bundle.group_info.get_group_info();
284                let conversation = conversation
285                    .external_join_via_group_info_notify(&guest, group_info)
286                    .await;
287
288                assert!(conversation.is_functional_and_contains([&alice, &guest]).await);
289                // because Bob has been removed from the group
290                assert!(!conversation.can_talk(&alice, &bob).await);
291            })
292            .await;
293        }
294    }
295
296    mod update_keying_material {
297        use super::*;
298
299        #[apply(all_cred_cipher)]
300        async fn should_succeed(case: TestContext) {
301            let [alice, bob] = case.sessions().await;
302            Box::pin(async move {
303                let conversation = case.create_conversation([&alice, &bob]).await;
304                let init_count = alice.transaction.count_entities().await;
305
306                let bob_keys = conversation
307                    .guard_of(&bob)
308                    .await
309                    .conversation()
310                    .await
311                    .encryption_keys()
312                    .collect::<Vec<Vec<u8>>>();
313                let alice_keys = conversation
314                    .guard()
315                    .await
316                    .conversation()
317                    .await
318                    .encryption_keys()
319                    .collect::<Vec<Vec<u8>>>();
320                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
321
322                let alice_key = conversation.encryption_public_key().await;
323
324                // proposing the key update for alice
325                let conversation = conversation.update_notify().await;
326                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
327                assert!(welcome.is_none());
328
329                let alice_new_keys = conversation
330                    .guard()
331                    .await
332                    .conversation()
333                    .await
334                    .encryption_keys()
335                    .collect::<Vec<Vec<u8>>>();
336                assert!(!alice_new_keys.contains(&alice_key));
337
338                let bob_new_keys = conversation
339                    .guard_of(&bob)
340                    .await
341                    .conversation()
342                    .await
343                    .encryption_keys()
344                    .collect::<Vec<Vec<u8>>>();
345                assert!(alice_new_keys.iter().all(|a_key| bob_new_keys.contains(a_key)));
346
347                // ensuring both can encrypt messages
348                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
349
350                // make sure inline update commit + merge does not leak anything
351                // that's obvious since no new encryption keypair is created in this case
352                let final_count = alice.transaction.count_entities().await;
353                assert_eq!(init_count, final_count);
354            })
355            .await;
356        }
357
358        #[apply(all_cred_cipher)]
359        async fn should_create_welcome_for_pending_add_proposals(case: TestContext) {
360            let [alice, bob, charlie] = case.sessions().await;
361            Box::pin(async move {
362                let conversation = case.create_conversation([&alice, &bob]).await;
363
364                let bob_keys = conversation
365                    .guard_of(&bob)
366                    .await
367                    .conversation()
368                    .await
369                    .signature_keys()
370                    .collect::<Vec<SignaturePublicKey>>();
371                let alice_keys = conversation
372                    .guard()
373                    .await
374                    .conversation()
375                    .await
376                    .signature_keys()
377                    .collect::<Vec<SignaturePublicKey>>();
378
379                // checking that the members on both sides are the same
380                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
381
382                let alice_key = conversation.encryption_public_key().await;
383
384                // proposing adding charlie
385                let conversation = conversation.invite_proposal_notify(&charlie).await;
386
387                assert!(
388                    conversation
389                        .guard()
390                        .await
391                        .conversation()
392                        .await
393                        .encryption_keys()
394                        .contains(&alice_key)
395                );
396
397                // The add proposal hasn't been committed yet
398                assert_eq!(conversation.member_count().await, 2);
399
400                // performing an update on Alice's key. this should generate a welcome for Charlie
401                let conversation = conversation.update_notify().await;
402                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
403                assert!(welcome.is_some());
404                assert!(
405                    !conversation
406                        .guard()
407                        .await
408                        .conversation()
409                        .await
410                        .encryption_keys()
411                        .contains(&alice_key)
412                );
413
414                assert_eq!(conversation.member_count().await, 3);
415
416                let alice_new_keys = conversation
417                    .guard()
418                    .await
419                    .conversation()
420                    .await
421                    .encryption_keys()
422                    .collect::<Vec<Vec<u8>>>();
423                assert!(!alice_new_keys.contains(&alice_key));
424
425                let bob_new_keys = conversation
426                    .guard_of(&bob)
427                    .await
428                    .conversation()
429                    .await
430                    .encryption_keys()
431                    .collect::<Vec<Vec<u8>>>();
432                assert!(alice_new_keys.iter().all(|a_key| bob_new_keys.contains(a_key)));
433
434                // ensure all parties can encrypt messages
435                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
436            })
437            .await;
438        }
439
440        #[apply(all_cred_cipher)]
441        async fn should_return_valid_welcome(case: TestContext) {
442            let [alice, bob, guest] = case.sessions().await;
443            Box::pin(async move {
444                let conversation = case
445                    .create_conversation([&alice, &bob])
446                    .await
447                    .invite_proposal_notify(&guest)
448                    .await
449                    .update_notify()
450                    .await;
451
452                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
453            })
454            .await;
455        }
456
457        #[apply(all_cred_cipher)]
458        async fn should_return_valid_group_info(case: TestContext) {
459            let [alice, bob, guest] = case.sessions().await;
460            Box::pin(async move {
461                let conversation = case.create_conversation([&alice, &bob]).await.update_notify().await;
462
463                let group_info = alice.mls_transport().await.latest_group_info().await;
464                let group_info = group_info.get_group_info();
465
466                let conversation = conversation
467                    .external_join_via_group_info_notify(&guest, group_info)
468                    .await;
469                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
470            })
471            .await;
472        }
473    }
474
475    mod commit_pending_proposals {
476        use super::*;
477
478        #[apply(all_cred_cipher)]
479        async fn should_create_a_commit_out_of_self_pending_proposals(case: TestContext) {
480            let [alice, bob] = case.sessions().await;
481            Box::pin(async move {
482                let conversation = case
483                    .create_conversation([&alice])
484                    .await
485                    .advance_epoch()
486                    .await
487                    .invite_proposal_notify(&bob)
488                    .await;
489
490                assert!(conversation.has_pending_proposals().await);
491                assert_eq!(conversation.member_count().await, 1);
492
493                let conversation = conversation.commit_pending_proposals_notify().await;
494                assert_eq!(conversation.member_count().await, 2);
495
496                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
497            })
498            .await;
499        }
500
501        #[apply(all_cred_cipher)]
502        async fn should_create_a_commit_out_of_pending_proposals_by_ref(case: TestContext) {
503            let [alice, bob, charlie] = case.sessions().await;
504            Box::pin(async move {
505                // Bob invites charlie
506                let conversation = case
507                    .create_conversation([&alice, &bob])
508                    .await
509                    .acting_as(&bob)
510                    .await
511                    .invite_proposal_notify(&charlie)
512                    .await
513                    .acting_as(&bob)
514                    .await;
515
516                assert!(conversation.has_pending_proposals().await);
517                assert_eq!(conversation.member_count().await, 2);
518
519                // Alice commits the proposal
520                let conversation = conversation.commit_pending_proposals_notify().await;
521                assert_eq!(conversation.member_count().await, 3);
522
523                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
524            })
525            .await;
526        }
527
528        #[apply(all_cred_cipher)]
529        async fn should_return_valid_welcome(case: TestContext) {
530            let [alice, bob] = case.sessions().await;
531            Box::pin(async move {
532                let conversation = case
533                    .create_conversation([&alice])
534                    .await
535                    .invite_proposal_notify(&bob)
536                    .await
537                    .commit_pending_proposals_notify()
538                    .await;
539
540                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
541            })
542            .await;
543        }
544
545        #[apply(all_cred_cipher)]
546        async fn should_return_valid_group_info(case: TestContext) {
547            let [alice, bob, guest] = case.sessions().await;
548            Box::pin(async move {
549                let conversation = case
550                    .create_conversation([&alice])
551                    .await
552                    .invite_proposal_notify(&bob)
553                    .await
554                    .commit_pending_proposals_notify()
555                    .await;
556                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
557                let group_info = commit_bundle.group_info.get_group_info();
558                let conversation = conversation
559                    .external_join_via_group_info_notify(&guest, group_info)
560                    .await;
561
562                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
563            })
564            .await;
565        }
566    }
567
568    mod delivery_semantics {
569        use super::*;
570
571        #[apply(all_cred_cipher)]
572        async fn should_prevent_out_of_order_commits(case: TestContext) {
573            let [alice, bob] = case.sessions().await;
574            Box::pin(async move {
575                let conversation = case.create_conversation([&alice, &bob]).await;
576                let id = conversation.id().clone();
577
578                let commit_guard = conversation.update().await;
579                let commit1 = commit_guard.message();
580                let commit1 = commit1.to_bytes().unwrap();
581
582                let commit_guard = commit_guard.finish().update().await;
583                let commit2 = commit_guard.message();
584                let commit2 = commit2.to_bytes().unwrap();
585
586                // fails when a commit is skipped
587                let out_of_order = bob
588                    .transaction
589                    .conversation(&id)
590                    .await
591                    .unwrap()
592                    .decrypt_message(&commit2)
593                    .await;
594                assert!(matches!(out_of_order.unwrap_err(), Error::BufferedFutureMessage { .. }));
595
596                // works in the right order though
597                // NB: here 'commit2' has been buffered so it is also applied when we decrypt commit1
598                bob.transaction
599                    .conversation(&id)
600                    .await
601                    .unwrap()
602                    .decrypt_message(&commit1)
603                    .await
604                    .unwrap();
605
606                // and then fails again when trying to decrypt a commit with an epoch in the past
607                let past_commit = bob
608                    .transaction
609                    .conversation(&id)
610                    .await
611                    .unwrap()
612                    .decrypt_message(&commit1)
613                    .await;
614                assert!(matches!(past_commit.unwrap_err(), Error::StaleCommit));
615            })
616            .await;
617        }
618
619        #[apply(all_cred_cipher)]
620        async fn should_prevent_replayed_encrypted_handshake_messages(case: TestContext) {
621            if !case.is_pure_ciphertext() {
622                return;
623            }
624
625            let [alice, bob] = case.sessions().await;
626            Box::pin(async move {
627                let conversation = case.create_conversation([&alice, &bob]).await;
628
629                let proposal_guard = conversation.update_proposal().await;
630                let proposal_replay = proposal_guard.message();
631
632                // replayed encrypted proposal should fail
633                let conversation = proposal_guard.notify_members().await;
634                assert!(matches!(
635                    conversation
636                        .guard_of(&bob)
637                        .await
638                        .decrypt_message(proposal_replay.to_bytes().unwrap())
639                        .await
640                        .unwrap_err(),
641                    Error::DuplicateMessage
642                ));
643
644                let commit_guard = conversation.update().await;
645                let commit_replay = commit_guard.message();
646
647                // replayed encrypted commit should fail
648                let conversation = commit_guard.notify_members().await;
649                assert!(matches!(
650                    conversation
651                        .guard_of(&bob)
652                        .await
653                        .decrypt_message(commit_replay.to_bytes().unwrap())
654                        .await
655                        .unwrap_err(),
656                    Error::StaleCommit
657                ));
658            })
659            .await;
660        }
661    }
662}