Skip to main content

core_crypto/mls/conversation/
commit.rs

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