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_basic() {
146            return;
147        }
148        let [alice] = case.sessions().await;
149        Box::pin(async move {
150            let x509_test_chain = alice.x509_chain_unchecked();
151
152            let conversation = case.create_conversation([&alice]).await;
153
154            assert!(!conversation.has_pending_commit().await);
155            let epoch = conversation.guard().await.epoch().await;
156
157            let intermediate_ca = x509_test_chain.find_local_intermediate_ca();
158
159            // In this case Alice will try to rotate her credential but her commit will be denied
160            // by the backend (because another commit from Bob had precedence)
161
162            // Alice creates a new Credential, updating her handle/display_name
163            let (new_handle, new_display_name) = ("new_alice_wire", "New Alice Smith");
164            let cb = alice
165                .save_new_credential(&case, new_handle, new_display_name, intermediate_ca)
166                .await;
167
168            // create a commit. This will also store it in the store
169            let commit_guard = conversation.e2ei_rotate_unmerged(&cb).await;
170            assert!(commit_guard.conversation().has_pending_commit().await);
171
172            // since the pending commit is the same as the incoming one, it should succeed
173            let conversation = commit_guard.notify_members_and_verify_sender().await;
174
175            let epoch_after_decrypt = conversation.guard().await.epoch().await;
176            assert_eq!(epoch + 1, epoch_after_decrypt);
177
178            // there is no proposals to renew here since it's our own commit we merge
179            assert!(!conversation.has_pending_proposals().await);
180
181            // verify that we return the new identity
182            alice
183                .verify_local_credential_rotated(conversation.id(), new_handle, new_display_name)
184                .await;
185        })
186        .await
187    }
188
189    // If there’s a pending commit & it does not match the self incoming commit: fail with dedicated error
190    #[apply(all_cred_cipher)]
191    #[wasm_bindgen_test]
192    pub async fn should_succeed_when_incoming_commit_mismatches_pending_commit(case: TestContext) {
193        if case.is_pure_ciphertext() {
194            return;
195        }
196        let [alice] = case.sessions().await;
197        Box::pin(async move {
198            let conversation = case.create_conversation([&alice]).await;
199
200            assert!(!conversation.has_pending_commit().await);
201
202            // create a first commit then discard it from the store to be able to create a second one
203            let commit_guard = conversation.update_unmerged().await;
204            let unmerged_commit = commit_guard.message();
205            assert!(commit_guard.conversation().has_pending_commit().await);
206            let conversation = commit_guard.finish();
207            conversation.guard().await.clear_pending_commit().await.unwrap();
208            assert!(!conversation.has_pending_commit().await);
209
210            // create another commit for the sole purpose of having it in the store
211            let commit_guard = conversation.update_unmerged().await;
212            let unmerged_commit2 = commit_guard.message();
213            assert_ne!(unmerged_commit, unmerged_commit2);
214            let conversation = commit_guard.finish();
215
216            let decrypt = conversation
217                .guard()
218                .await
219                .decrypt_message(&unmerged_commit.to_bytes().unwrap())
220                .await;
221            assert!(matches!(decrypt.unwrap_err(), Error::ClearingPendingCommitError));
222        })
223        .await
224    }
225
226    // if there’s no pending commit & and the incoming commit originates from self: succeed by ignoring the incoming commit
227    #[apply(all_cred_cipher)]
228    #[wasm_bindgen_test]
229    pub async fn should_ignore_self_incoming_commit_when_no_pending_commit(case: TestContext) {
230        if case.is_pure_ciphertext() {
231            return;
232        }
233        let [alice] = case.sessions().await;
234        Box::pin(async move {
235            let conversation = case.create_conversation([&alice]).await;
236
237            assert!(!conversation.has_pending_commit().await);
238
239            // create a commit, have it in store...
240            let commit_guard = conversation.update_unmerged().await;
241            let conversation = commit_guard.conversation();
242            assert!(conversation.has_pending_commit().await);
243
244            // then delete the pending commit
245            conversation.guard().await.clear_pending_commit().await.unwrap();
246            assert!(!conversation.has_pending_commit().await);
247
248            let (_, decrypt_self) = commit_guard.notify_member_fallible(&alice).await;
249            // this means DS replayed the commit. In that case just ignore, we have already merged the commit anyway
250            assert!(matches!(decrypt_self.unwrap_err(), Error::SelfCommitIgnored));
251        })
252        .await
253    }
254
255    #[apply(all_cred_cipher)]
256    #[wasm_bindgen_test]
257    pub async fn should_fail_when_tampering_with_incoming_own_commit_same_as_pending(case: TestContext) {
258        use crate::MlsErrorKind;
259
260        if case.is_pure_ciphertext() {
261            // The use case tested here requires inspecting your own commit.
262            // Openmls does not support this currently when protocol messages are encrypted.
263            return;
264        }
265
266        let [alice] = case.sessions().await;
267        let conversation = case.create_conversation([&alice]).await;
268        Box::pin(async move {
269            // No pending commit yet.
270            assert!(!conversation.has_pending_commit().await);
271
272            // Create the commit that we're going to tamper with.
273            let commit_guard = conversation.update_unmerged().await;
274            let add_bob_message = commit_guard.message();
275            let conversation = commit_guard.conversation();
276
277            // Now there is a pending commit.
278            assert!(conversation.has_pending_commit().await);
279
280            let commit_serialized = &mut add_bob_message.to_bytes().unwrap();
281
282            // Tamper with the commit; this is the signature region, however,
283            // the membership tag covers the signature, so this will result in an
284            // invalid membership tag error emitted by openmls.
285            commit_serialized[300] = commit_serialized[300].wrapping_add(1);
286
287            let decryption_result = conversation.guard().await.decrypt_message(commit_serialized).await;
288            let error = decryption_result.unwrap_err();
289            assert!(matches!(
290                error,
291                Error::Mls(MlsError {
292                    source: MlsErrorKind::MlsMessageError(ProcessMessageError::ValidationError(
293                        ValidationError::InvalidMembershipTag
294                    )),
295                    ..
296                })
297            ));
298
299            // There is still a pending commit.
300            assert!(conversation.has_pending_commit().await);
301
302            // Positive case: Alice decrypts the commit...
303            assert!(
304                conversation
305                    .guard()
306                    .await
307                    .decrypt_message(&add_bob_message.to_bytes().unwrap())
308                    .await
309                    .is_ok()
310            );
311
312            // ...and has cleared the pending commit.
313            assert!(!conversation.has_pending_commit().await);
314        })
315        .await
316    }
317
318    #[apply(all_cred_cipher)]
319    #[wasm_bindgen_test]
320    async fn should_succeed_when_incoming_commit_is_self_commit_but_was_lost(case: TestContext) {
321        if case.is_pure_ciphertext() {
322            // The use case tested here requires inspecting your own commit.
323            // Openmls does not support this currently when protocol messages are encrypted.
324            return;
325        }
326
327        let [mut alice, bob] = case.sessions().await;
328        let conversation = case.create_conversation([&alice, &bob]).await;
329        let conversation_id = conversation.id().to_owned();
330
331        drop(conversation);
332        // Commit the transaction here; this is the state alice will be in when reloading the app after crashing.
333        alice.commit_transaction().await;
334        let conversation = TestConversation::new_from_existing(&case, conversation_id.clone(), [&alice, &bob]).await;
335
336        // Alice creates a commit but won't merge it immediately.
337        // In the meantime, Bob merges that commit.
338        let commit_guard = conversation.update_unmerged().await.notify_member(&bob).await;
339        let unmerged_commit = commit_guard.message().to_bytes().unwrap();
340        let _conversation = commit_guard.finish();
341
342        // Alice's app may have crashed, for example, before receiving the success response from the DS.
343        // Crash happens here; changes since the transaction commit are not persisted.
344        alice.pretend_crash().await;
345
346        // ok, alice is back, and look: here's that commit that she made
347        alice
348            .transaction
349            .conversation(&conversation_id)
350            .await
351            .unwrap()
352            .decrypt_message(&unmerged_commit)
353            .await
354            .unwrap_err();
355        //  .unwrap();
356        //
357        // We _want_ this case to work, and spent some effort attempting to make it work, but ultimately
358        // couldn't figure out how to make it work given the OpenMLS primitives available. Ref: [WPB-17464].
359        return;
360
361        #[expect(unreachable_code)]
362        {
363            // mls is still healthy and Alice and Bob can still chat
364            assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
365        }
366    }
367}