core_crypto/mls/conversation/
own_commit.rs

1use super::{Error, Result};
2use crate::{
3    RecursiveError,
4    mls::credential::{
5        crl::{extract_crl_uris_from_group, get_new_crl_distribution_points},
6        ext::CredentialExt,
7    },
8    prelude::{MlsConversation, MlsConversationDecryptMessage, Session},
9};
10use mls_crypto_provider::MlsCryptoProvider;
11use openmls::prelude::{
12    ConfirmationTag, ContentType, CredentialWithKey, FramedContentBodyIn, MlsMessageIn, MlsMessageInBody, Sender,
13};
14
15impl MlsConversation {
16    /// Returns the confirmation tag from a public message that is an own commit.
17    /// Returns an error if the confirmation tag in the own commit is missing.
18    pub(crate) fn extract_confirmation_tag_from_own_commit<'a>(
19        &self,
20        own_commit: &'a MlsMessageIn,
21    ) -> Result<&'a ConfirmationTag> {
22        if let MlsMessageInBody::PublicMessage(msg) = own_commit.body_as_ref() {
23            let is_commit = matches!(msg.content_type(), ContentType::Commit);
24            let own_index = self.group.own_leaf_index();
25            let is_self_sent = matches!(msg.sender(), Sender::Member(i) if i == &own_index);
26            let is_own_commit = is_commit && is_self_sent;
27
28            assert!(
29                is_own_commit,
30                "extract_confirmation_tag_from_own_commit() must always be called with an own commit."
31            );
32            assert!(
33                matches!(msg.body(), FramedContentBodyIn::Commit(_)),
34                "extract_confirmation_tag_from_own_commit() must always be called with an own commit."
35            );
36
37            msg.auth
38                .confirmation_tag
39                .as_ref()
40                .ok_or(Error::MlsMessageInvalidState("Message confirmation tag not present"))
41        } else {
42            panic!(
43                "extract_confirmation_tag_from_own_commit() must always be called \
44                 with an MlsMessageIn containing an MlsMessageInBody::PublicMessage"
45            );
46        }
47    }
48
49    pub(crate) async fn handle_own_commit(
50        &mut self,
51        client: &Session,
52        backend: &MlsCryptoProvider,
53        ct: &ConfirmationTag,
54    ) -> Result<MlsConversationDecryptMessage> {
55        if self.group.pending_commit().is_none() {
56            // This either means the DS replayed one of our commit OR we cleared a commit accepted by the DS
57            // In both cases, CoreCrypto cannot be of any help since it cannot decrypt self commits
58            // => deflect this case and let the caller handle it
59            return Err(Error::SelfCommitIgnored);
60        }
61
62        if !self.eq_pending_commit(ct) {
63            // this would mean we created a commit that got accepted by the DS but we cleared it locally
64            // then somehow retried and created another commit. This is a manifest client error
65            // and should be identified as such
66            return Err(Error::ClearingPendingCommitError);
67        }
68
69        // incoming is from ourselves and it's the same as the local pending commit
70        // => merge the pending commit & continue
71        self.merge_pending_commit(client, backend).await
72    }
73
74    /// Compare incoming commit with local pending commit
75    pub(crate) fn eq_pending_commit(&self, commit_ct: &ConfirmationTag) -> bool {
76        if let Some(pending_commit) = self.group.pending_commit() {
77            return pending_commit.get_confirmation_tag() == commit_ct;
78        }
79        false
80    }
81
82    /// When the incoming commit is sent by ourselves and it's the same as the local pending commit.
83    /// This adapts [Self::commit_accepted] to return the same as [MlsConversation::decrypt_message]
84    pub(crate) async fn merge_pending_commit(
85        &mut self,
86        client: &Session,
87        backend: &MlsCryptoProvider,
88    ) -> Result<MlsConversationDecryptMessage> {
89        self.commit_accepted(client, backend).await?;
90
91        let own_leaf = self
92            .group
93            .own_leaf()
94            .ok_or(Error::MlsGroupInvalidState("own_leaf is None"))?;
95
96        // We return self identity here, probably not necessary to check revocation
97        let own_leaf_credential_with_key = CredentialWithKey {
98            credential: own_leaf.credential().clone(),
99            signature_key: own_leaf.signature_key().clone(),
100        };
101        let identity = own_leaf_credential_with_key
102            .extract_identity(self.ciphersuite(), None)
103            .map_err(RecursiveError::mls_credential("extracting identity"))?;
104
105        let crl_new_distribution_points = get_new_crl_distribution_points(
106            backend,
107            extract_crl_uris_from_group(&self.group)
108                .map_err(RecursiveError::mls_credential("extracting crl uris from group"))?,
109        )
110        .await
111        .map_err(RecursiveError::mls_credential("getting new crl distribution points"))?;
112
113        // we still support the `has_epoch_changed` field, though we'll remove it later
114        #[expect(deprecated)]
115        Ok(MlsConversationDecryptMessage {
116            app_msg: None,
117            proposals: vec![],
118            is_active: self.group.is_active(),
119            delay: self.compute_next_commit_delay(),
120            sender_client_id: None,
121            has_epoch_changed: true,
122            identity,
123            buffered_messages: None,
124            crl_new_distribution_points,
125        })
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use crate::test_utils::*;
132    use openmls::prelude::{ProcessMessageError, ValidationError};
133
134    use super::super::error::Error;
135    use crate::prelude::MlsError;
136
137    use crate::mls::conversation::Conversation as _;
138    use wasm_bindgen_test::*;
139
140    wasm_bindgen_test_configure!(run_in_browser);
141    // If there’s a pending commit & it matches the incoming commit: mark pending commit as accepted
142    #[apply(all_cred_cipher)]
143    #[wasm_bindgen_test]
144    pub async fn should_succeed_when_incoming_commit_same_as_pending(case: TestContext) {
145        if !case.is_pure_ciphertext() && case.is_x509() {
146            let [alice_central] = case.sessions().await;
147            Box::pin(async move {
148                let x509_test_chain = alice_central
149                    .x509_test_chain
150                    .as_ref()
151                    .as_ref()
152                    .expect("No x509 test chain");
153
154                let id = conversation_id();
155                alice_central
156                    .transaction
157                    .new_conversation(&id, case.credential_type, case.cfg.clone())
158                    .await
159                    .unwrap();
160
161                assert!(alice_central.pending_commit(&id).await.is_none());
162
163                let intermediate_ca = x509_test_chain.find_local_intermediate_ca();
164
165                // In this case Alice will try to rotate her credential but her commit will be denied
166                // by the backend (because another commit from Bob had precedence)
167
168                // Alice creates a new Credential, updating her handle/display_name
169                let (new_handle, new_display_name) = ("new_alice_wire", "New Alice Smith");
170                let cb = alice_central
171                    .save_new_credential(&case, new_handle, new_display_name, intermediate_ca)
172                    .await;
173
174                // create a commit. This will also store it in the store
175                let commit = alice_central.create_unmerged_e2ei_rotate_commit(&id, &cb).await;
176                assert!(alice_central.pending_commit(&id).await.is_some());
177
178                let epoch = alice_central.transaction.conversation(&id).await.unwrap().epoch().await;
179
180                // since the pending commit is the same as the incoming one, it should succeed
181                let decrypt_self = alice_central
182                    .transaction
183                    .conversation(&id)
184                    .await
185                    .unwrap()
186                    .decrypt_message(&commit.commit.to_bytes().unwrap())
187                    .await;
188                assert!(decrypt_self.is_ok());
189                let decrypt_self = decrypt_self.unwrap();
190
191                let epoch_after_decrypt = alice_central.transaction.conversation(&id).await.unwrap().epoch().await;
192                assert_eq!(epoch + 1, epoch_after_decrypt);
193
194                // there is no proposals to renew here since it's our own commit we merge
195                assert!(decrypt_self.proposals.is_empty());
196
197                // verify that we return the new identity
198                alice_central.verify_sender_identity(&case, &decrypt_self).await;
199                alice_central
200                    .verify_local_credential_rotated(&id, new_handle, new_display_name)
201                    .await;
202            })
203            .await
204        }
205    }
206
207    // If there’s a pending commit & it does not match the self incoming commit: fail with dedicated error
208    #[apply(all_cred_cipher)]
209    #[wasm_bindgen_test]
210    pub async fn should_succeed_when_incoming_commit_mismatches_pending_commit(case: TestContext) {
211        if !case.is_pure_ciphertext() {
212            let [alice_central] = case.sessions().await;
213            Box::pin(async move {
214                let id = conversation_id();
215                alice_central
216                    .transaction
217                    .new_conversation(&id, case.credential_type, case.cfg.clone())
218                    .await
219                    .unwrap();
220
221                assert!(alice_central.pending_commit(&id).await.is_none());
222
223                // create a first commit then discard it from the store to be able to create a second one
224                let unmerged_commit = alice_central.create_unmerged_commit(&id).await.commit;
225                assert!(alice_central.pending_commit(&id).await.is_some());
226                alice_central
227                    .transaction
228                    .conversation(&id)
229                    .await
230                    .unwrap()
231                    .clear_pending_commit()
232                    .await
233                    .unwrap();
234                assert!(alice_central.pending_commit(&id).await.is_none());
235
236                // create another commit for the sole purpose of having it in the store
237                let unmerged_commit2 = alice_central.create_unmerged_commit(&id).await.commit;
238                assert_ne!(unmerged_commit, unmerged_commit2);
239
240                let decrypt = alice_central
241                    .transaction
242                    .conversation(&id)
243                    .await
244                    .unwrap()
245                    .decrypt_message(&unmerged_commit.to_bytes().unwrap())
246                    .await;
247                assert!(matches!(decrypt.unwrap_err(), Error::ClearingPendingCommitError));
248            })
249            .await
250        }
251    }
252
253    // if there’s no pending commit & and the incoming commit originates from self: succeed by ignoring the incoming commit
254    #[apply(all_cred_cipher)]
255    #[wasm_bindgen_test]
256    pub async fn should_ignore_self_incoming_commit_when_no_pending_commit(case: TestContext) {
257        if !case.is_pure_ciphertext() {
258            let [alice_central] = case.sessions().await;
259            Box::pin(async move {
260                let id = conversation_id();
261                alice_central
262                    .transaction
263                    .new_conversation(&id, case.credential_type, case.cfg.clone())
264                    .await
265                    .unwrap();
266
267                assert!(alice_central.pending_commit(&id).await.is_none());
268
269                // create a commit, have it in store...
270                let commit = alice_central.create_unmerged_commit(&id).await.commit;
271                assert!(alice_central.pending_commit(&id).await.is_some());
272
273                // then delete the pending commit
274                alice_central
275                    .transaction
276                    .conversation(&id)
277                    .await
278                    .unwrap()
279                    .clear_pending_commit()
280                    .await
281                    .unwrap();
282                assert!(alice_central.pending_commit(&id).await.is_none());
283
284                let decrypt_self = alice_central
285                    .transaction
286                    .conversation(&id)
287                    .await
288                    .unwrap()
289                    .decrypt_message(&commit.to_bytes().unwrap())
290                    .await;
291                // this means DS replayed the commit. In that case just ignore, we have already merged the commit anyway
292                assert!(matches!(decrypt_self.unwrap_err(), Error::SelfCommitIgnored));
293            })
294            .await
295        }
296    }
297
298    #[apply(all_cred_cipher)]
299    #[wasm_bindgen_test]
300    pub async fn should_fail_when_tampering_with_incoming_own_commit_same_as_pending(case: TestContext) {
301        use crate::MlsErrorKind;
302
303        if case.is_pure_ciphertext() {
304            // The use case tested here requires inspecting your own commit.
305            // Openmls does not support this currently when protocol messages are encrypted.
306            return;
307        }
308
309        let [alice_central] = case.sessions().await;
310        Box::pin(async move {
311            let conversation_id = conversation_id();
312            alice_central
313                .transaction
314                .new_conversation(&conversation_id, case.credential_type, case.cfg.clone())
315                .await
316                .unwrap();
317
318            // No pending commit yet.
319            assert!(alice_central.pending_commit(&conversation_id).await.is_none());
320
321            // Create the commit that we're going to tamper with.
322            let add_bob_message = alice_central.create_unmerged_commit(&conversation_id).await.commit;
323
324            // Now there is a pending commit.
325            assert!(alice_central.pending_commit(&conversation_id).await.is_some());
326
327            let commit_serialized = &mut add_bob_message.to_bytes().unwrap();
328
329            // Tamper with the commit; this is the signature region, however,
330            // the membership tag covers the signature, so this will result in an
331            // invalid membership tag error emitted by openmls.
332            commit_serialized[300] = commit_serialized[300].wrapping_add(1);
333
334            let decryption_result = alice_central
335                .transaction
336                .conversation(&conversation_id)
337                .await
338                .unwrap()
339                .decrypt_message(commit_serialized)
340                .await;
341            let error = decryption_result.unwrap_err();
342            assert!(matches!(
343                error,
344                Error::Mls(MlsError {
345                    source: MlsErrorKind::MlsMessageError(ProcessMessageError::ValidationError(
346                        ValidationError::InvalidMembershipTag
347                    )),
348                    ..
349                })
350            ));
351
352            // There is still a pending commit.
353            assert!(alice_central.pending_commit(&conversation_id).await.is_some());
354
355            // Positive case: Alice decrypts the commit...
356            assert!(
357                alice_central
358                    .transaction
359                    .conversation(&conversation_id)
360                    .await
361                    .unwrap()
362                    .decrypt_message(&add_bob_message.to_bytes().unwrap())
363                    .await
364                    .is_ok()
365            );
366
367            // ...and has cleared the pending commit.
368            assert!(alice_central.pending_commit(&conversation_id).await.is_none());
369        })
370        .await
371    }
372}