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::test_utils::*;
56    use crate::transaction_context::Error as TransactionError;
57
58    use super::{Error, *};
59
60    mod transport {
61        use super::*;
62        use std::sync::Arc;
63
64        #[apply(all_cred_cipher)]
65        async fn retry_should_work(case: TestContext) {
66            use crate::mls::conversation::Conversation as _;
67
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!(alice.get_conversation_unchecked(&id).await.members().len(), 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!(alice.get_conversation_unchecked(&id).await.id, id);
142                assert_eq!(
143                    alice.get_conversation_unchecked(&id).await.group.group_id().as_slice(),
144                    id
145                );
146                assert_eq!(alice.get_conversation_unchecked(&id).await.members().len(), 2);
147                assert_eq!(
148                    alice.get_conversation_unchecked(&id).await.id(),
149                    bob.get_conversation_unchecked(&id).await.id()
150                );
151                assert_eq!(bob.get_conversation_unchecked(&id).await.members().len(), 2);
152                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
153            })
154            .await
155        }
156
157        #[apply(all_cred_cipher)]
158        async fn should_return_valid_welcome(case: TestContext) {
159            let [alice, bob] = case.sessions().await;
160            Box::pin(async move {
161                let conversation = case.create_conversation([&alice, &bob]).await;
162                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
163            })
164            .await
165        }
166
167        #[apply(all_cred_cipher)]
168        async fn should_return_valid_group_info(case: TestContext) {
169            let [alice, bob, guest] = case.sessions().await;
170            Box::pin(async move {
171                let conversation = case.create_conversation([&alice, &bob]).await;
172                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
173                let group_info = commit_bundle.group_info.get_group_info();
174                let conversation = conversation
175                    .external_join_via_group_info_notify(&guest, group_info)
176                    .await;
177                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
178            })
179            .await
180        }
181    }
182
183    mod remove_members {
184        use super::*;
185
186        #[apply(all_cred_cipher)]
187        async fn alice_can_remove_bob_from_conversation(case: TestContext) {
188            let [alice, bob] = case.sessions().await;
189            Box::pin(async move {
190                let conversation = case.create_conversation([&alice, &bob]).await.remove_notify(&bob).await;
191                let id = conversation.id().clone();
192
193                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
194                assert!(welcome.is_none());
195
196                assert_eq!(conversation.member_count().await, 1);
197
198                // But has been removed from the conversation
199                assert!(matches!(
200                bob.transaction.conversation(&id).await.unwrap_err(),
201                TransactionError::Leaf(crate::LeafError::ConversationNotFound(ref i))
202                    if i == &id
203                ));
204                assert!(!conversation.can_talk(&alice, &bob).await);
205            })
206            .await;
207        }
208
209        #[apply(all_cred_cipher)]
210        async fn should_return_valid_welcome(case: TestContext) {
211            let [alice, bob, guest] = case.sessions().await;
212            Box::pin(async move {
213                let conversation = case
214                    .create_conversation([&alice, &bob])
215                    .await
216                    .invite_proposal_notify(&guest)
217                    .await
218                    .remove_notify(&bob)
219                    .await;
220
221                assert!(conversation.is_functional_and_contains([&alice, &guest]).await);
222                // because Bob has been removed from the group
223                assert!(!conversation.can_talk(&alice, &bob).await);
224            })
225            .await;
226        }
227
228        #[apply(all_cred_cipher)]
229        async fn should_return_valid_group_info(case: TestContext) {
230            let [alice, bob, guest] = case.sessions().await;
231            Box::pin(async move {
232                let conversation = case.create_conversation([&alice, &bob]).await.remove_notify(&bob).await;
233                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
234                let group_info = commit_bundle.group_info.get_group_info();
235                let conversation = conversation
236                    .external_join_via_group_info_notify(&guest, group_info)
237                    .await;
238
239                assert!(conversation.is_functional_and_contains([&alice, &guest]).await);
240                // because Bob has been removed from the group
241                assert!(!conversation.can_talk(&alice, &bob).await);
242            })
243            .await;
244        }
245    }
246
247    mod update_keying_material {
248        use super::*;
249
250        #[apply(all_cred_cipher)]
251        async fn should_succeed(case: TestContext) {
252            let [alice, bob] = case.sessions().await;
253            Box::pin(async move {
254                let conversation = case.create_conversation([&alice, &bob]).await;
255                let id = conversation.id().clone();
256
257                let init_count = alice.transaction.count_entities().await;
258
259                let bob_keys = bob
260                    .get_conversation_unchecked(&id)
261                    .await
262                    .encryption_keys()
263                    .collect::<Vec<Vec<u8>>>();
264                let alice_keys = alice
265                    .get_conversation_unchecked(&id)
266                    .await
267                    .encryption_keys()
268                    .collect::<Vec<Vec<u8>>>();
269                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
270
271                let alice_key = alice.encryption_key_of(&id, alice.get_client_id().await).await;
272
273                // proposing the key update for alice
274                let conversation = conversation.update_notify().await;
275                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
276                assert!(welcome.is_none());
277
278                assert!(
279                    !alice
280                        .get_conversation_unchecked(&id)
281                        .await
282                        .encryption_keys()
283                        .contains(&alice_key)
284                );
285
286                let alice_new_keys = alice
287                    .get_conversation_unchecked(&id)
288                    .await
289                    .encryption_keys()
290                    .collect::<Vec<_>>();
291                assert!(!alice_new_keys.contains(&alice_key));
292
293                let bob_new_keys = bob
294                    .get_conversation_unchecked(&id)
295                    .await
296                    .encryption_keys()
297                    .collect::<Vec<_>>();
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                let id = conversation.id().clone();
317
318                let bob_keys = bob
319                    .get_conversation_unchecked(&id)
320                    .await
321                    .signature_keys()
322                    .collect::<Vec<SignaturePublicKey>>();
323                let alice_keys = alice
324                    .get_conversation_unchecked(&id)
325                    .await
326                    .signature_keys()
327                    .collect::<Vec<SignaturePublicKey>>();
328
329                // checking that the members on both sides are the same
330                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
331
332                let alice_key = alice.encryption_key_of(&id, alice.get_client_id().await).await;
333
334                // proposing adding charlie
335                let conversation = conversation.invite_proposal_notify(&charlie).await;
336
337                assert!(
338                    alice
339                        .get_conversation_unchecked(&id)
340                        .await
341                        .encryption_keys()
342                        .contains(&alice_key)
343                );
344
345                // The add proposal hasn't been committed yet
346                assert_eq!(conversation.member_count().await, 2);
347
348                // performing an update on Alice's key. this should generate a welcome for Charlie
349                let conversation = conversation.update_notify().await;
350                let MlsCommitBundle { welcome, .. } = alice.mls_transport().await.latest_commit_bundle().await;
351                assert!(welcome.is_some());
352                assert!(
353                    !alice
354                        .get_conversation_unchecked(&id)
355                        .await
356                        .encryption_keys()
357                        .contains(&alice_key)
358                );
359
360                assert_eq!(conversation.member_count().await, 3);
361
362                let alice_new_keys = alice
363                    .get_conversation_unchecked(&id)
364                    .await
365                    .encryption_keys()
366                    .collect::<Vec<Vec<u8>>>();
367                assert!(!alice_new_keys.contains(&alice_key));
368
369                let bob_new_keys = bob
370                    .get_conversation_unchecked(&id)
371                    .await
372                    .encryption_keys()
373                    .collect::<Vec<Vec<u8>>>();
374                assert!(alice_new_keys.iter().all(|a_key| bob_new_keys.contains(a_key)));
375
376                // ensure all parties can encrypt messages
377                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
378            })
379            .await;
380        }
381
382        #[apply(all_cred_cipher)]
383        async fn should_return_valid_welcome(case: TestContext) {
384            let [alice, bob, guest] = case.sessions().await;
385            Box::pin(async move {
386                let conversation = case
387                    .create_conversation([&alice, &bob])
388                    .await
389                    .invite_proposal_notify(&guest)
390                    .await
391                    .update_notify()
392                    .await;
393
394                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
395            })
396            .await;
397        }
398
399        #[apply(all_cred_cipher)]
400        async fn should_return_valid_group_info(case: TestContext) {
401            let [alice, bob, guest] = case.sessions().await;
402            Box::pin(async move {
403                let conversation = case.create_conversation([&alice, &bob]).await.update_notify().await;
404
405                let group_info = alice.mls_transport().await.latest_group_info().await;
406                let group_info = group_info.get_group_info();
407
408                let conversation = conversation
409                    .external_join_via_group_info_notify(&guest, group_info)
410                    .await;
411                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
412            })
413            .await;
414        }
415    }
416
417    mod commit_pending_proposals {
418        use super::*;
419
420        #[apply(all_cred_cipher)]
421        async fn should_create_a_commit_out_of_self_pending_proposals(case: TestContext) {
422            let [alice, bob] = case.sessions().await;
423            Box::pin(async move {
424                let conversation = case
425                    .create_conversation([&alice])
426                    .await
427                    .advance_epoch()
428                    .await
429                    .invite_proposal_notify(&bob)
430                    .await;
431                let id = conversation.id.clone();
432
433                assert!(!alice.pending_proposals(&id).await.is_empty());
434                assert_eq!(conversation.member_count().await, 1);
435
436                let conversation = conversation.commit_pending_proposals_notify().await;
437                assert_eq!(conversation.member_count().await, 2);
438
439                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
440            })
441            .await;
442        }
443
444        #[apply(all_cred_cipher)]
445        async fn should_create_a_commit_out_of_pending_proposals_by_ref(case: TestContext) {
446            let [alice, bob, charlie] = case.sessions().await;
447            Box::pin(async move {
448                // Bob invites charlie
449                let conversation = case
450                    .create_conversation([&alice, &bob])
451                    .await
452                    .acting_as(&bob)
453                    .await
454                    .invite_proposal_notify(&charlie)
455                    .await;
456
457                assert!(!bob.pending_proposals(conversation.id()).await.is_empty());
458                assert_eq!(conversation.member_count().await, 2);
459
460                // Alice commits the proposal
461                let conversation = conversation.commit_pending_proposals_notify().await;
462                assert_eq!(conversation.member_count().await, 3);
463
464                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
465            })
466            .await;
467        }
468
469        #[apply(all_cred_cipher)]
470        async fn should_return_valid_welcome(case: TestContext) {
471            let [alice, bob] = case.sessions().await;
472            Box::pin(async move {
473                let conversation = case
474                    .create_conversation([&alice])
475                    .await
476                    .invite_proposal_notify(&bob)
477                    .await
478                    .commit_pending_proposals_notify()
479                    .await;
480
481                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
482            })
483            .await;
484        }
485
486        #[apply(all_cred_cipher)]
487        async fn should_return_valid_group_info(case: TestContext) {
488            let [alice, bob, guest] = case.sessions().await;
489            Box::pin(async move {
490                let conversation = case
491                    .create_conversation([&alice])
492                    .await
493                    .invite_proposal_notify(&bob)
494                    .await
495                    .commit_pending_proposals_notify()
496                    .await;
497                let commit_bundle = alice.mls_transport().await.latest_commit_bundle().await;
498                let group_info = commit_bundle.group_info.get_group_info();
499                let conversation = conversation
500                    .external_join_via_group_info_notify(&guest, group_info)
501                    .await;
502
503                assert!(conversation.is_functional_and_contains([&alice, &bob, &guest]).await);
504            })
505            .await;
506        }
507    }
508
509    mod delivery_semantics {
510        use super::*;
511
512        #[apply(all_cred_cipher)]
513        async fn should_prevent_out_of_order_commits(case: TestContext) {
514            let [alice, bob] = case.sessions().await;
515            Box::pin(async move {
516                let conversation = case.create_conversation([&alice, &bob]).await;
517                let id = conversation.id().clone();
518
519                let commit_guard = conversation.update().await;
520                let commit1 = commit_guard.message();
521                let commit1 = commit1.to_bytes().unwrap();
522
523                let commit_guard = commit_guard.finish().update().await;
524                let commit2 = commit_guard.message();
525                let commit2 = commit2.to_bytes().unwrap();
526
527                // fails when a commit is skipped
528                let out_of_order = bob
529                    .transaction
530                    .conversation(&id)
531                    .await
532                    .unwrap()
533                    .decrypt_message(&commit2)
534                    .await;
535                assert!(matches!(out_of_order.unwrap_err(), Error::BufferedFutureMessage { .. }));
536
537                // works in the right order though
538                // NB: here 'commit2' has been buffered so it is also applied when we decrypt commit1
539                bob.transaction
540                    .conversation(&id)
541                    .await
542                    .unwrap()
543                    .decrypt_message(&commit1)
544                    .await
545                    .unwrap();
546
547                // and then fails again when trying to decrypt a commit with an epoch in the past
548                let past_commit = bob
549                    .transaction
550                    .conversation(&id)
551                    .await
552                    .unwrap()
553                    .decrypt_message(&commit1)
554                    .await;
555                assert!(matches!(past_commit.unwrap_err(), Error::StaleCommit));
556            })
557            .await;
558        }
559
560        #[apply(all_cred_cipher)]
561        async fn should_prevent_replayed_encrypted_handshake_messages(case: TestContext) {
562            if !case.is_pure_ciphertext() {
563                return;
564            }
565
566            let [alice, bob] = case.sessions().await;
567            Box::pin(async move {
568                let conversation = case.create_conversation([&alice, &bob]).await;
569
570                let proposal_guard = conversation.update_proposal().await;
571                let proposal_replay = proposal_guard.message();
572
573                // replayed encrypted proposal should fail
574                let conversation = proposal_guard.notify_members().await;
575                assert!(matches!(
576                    conversation
577                        .guard_of(&bob)
578                        .await
579                        .decrypt_message(proposal_replay.to_bytes().unwrap())
580                        .await
581                        .unwrap_err(),
582                    Error::DuplicateMessage
583                ));
584
585                let commit_guard = conversation.update().await;
586                let commit_replay = commit_guard.message();
587
588                // replayed encrypted commit should fail
589                let conversation = commit_guard.notify_members().await;
590                assert!(matches!(
591                    conversation
592                        .guard_of(&bob)
593                        .await
594                        .decrypt_message(commit_replay.to_bytes().unwrap())
595                        .await
596                        .unwrap_err(),
597                    Error::StaleCommit
598                ));
599            })
600            .await;
601        }
602    }
603}