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