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            run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
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(
172                            &case,
173                            new_handle,
174                            new_display_name,
175                            x509_test_chain.find_certificate_for_actor("alice").unwrap(),
176                            intermediate_ca,
177                        )
178                        .await;
179
180                    // create a commit. This will also store it in the store
181                    let commit = alice_central.create_unmerged_e2ei_rotate_commit(&id, &cb).await;
182                    assert!(alice_central.pending_commit(&id).await.is_some());
183
184                    let epoch = alice_central.transaction.conversation(&id).await.unwrap().epoch().await;
185
186                    // since the pending commit is the same as the incoming one, it should succeed
187                    let decrypt_self = alice_central
188                        .transaction
189                        .conversation(&id)
190                        .await
191                        .unwrap()
192                        .decrypt_message(&commit.commit.to_bytes().unwrap())
193                        .await;
194                    assert!(decrypt_self.is_ok());
195                    let decrypt_self = decrypt_self.unwrap();
196
197                    let epoch_after_decrypt = alice_central.transaction.conversation(&id).await.unwrap().epoch().await;
198                    assert_eq!(epoch + 1, epoch_after_decrypt);
199
200                    // there is no proposals to renew here since it's our own commit we merge
201                    assert!(decrypt_self.proposals.is_empty());
202
203                    // verify that we return the new identity
204                    alice_central.verify_sender_identity(&case, &decrypt_self).await;
205                    alice_central
206                        .verify_local_credential_rotated(&id, new_handle, new_display_name)
207                        .await;
208                })
209            })
210            .await
211        }
212    }
213
214    // If there’s a pending commit & it does not match the self incoming commit: fail with dedicated error
215    #[apply(all_cred_cipher)]
216    #[wasm_bindgen_test]
217    pub async fn should_succeed_when_incoming_commit_mismatches_pending_commit(case: TestContext) {
218        if !case.is_pure_ciphertext() {
219            run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
220                Box::pin(async move {
221                    let id = conversation_id();
222                    alice_central
223                        .transaction
224                        .new_conversation(&id, case.credential_type, case.cfg.clone())
225                        .await
226                        .unwrap();
227
228                    assert!(alice_central.pending_commit(&id).await.is_none());
229
230                    // create a first commit then discard it from the store to be able to create a second one
231                    let unmerged_commit = alice_central.create_unmerged_commit(&id).await.commit;
232                    assert!(alice_central.pending_commit(&id).await.is_some());
233                    alice_central
234                        .transaction
235                        .conversation(&id)
236                        .await
237                        .unwrap()
238                        .clear_pending_commit()
239                        .await
240                        .unwrap();
241                    assert!(alice_central.pending_commit(&id).await.is_none());
242
243                    // create another commit for the sole purpose of having it in the store
244                    let unmerged_commit2 = alice_central.create_unmerged_commit(&id).await.commit;
245                    assert_ne!(unmerged_commit, unmerged_commit2);
246
247                    let decrypt = alice_central
248                        .transaction
249                        .conversation(&id)
250                        .await
251                        .unwrap()
252                        .decrypt_message(&unmerged_commit.to_bytes().unwrap())
253                        .await;
254                    assert!(matches!(decrypt.unwrap_err(), Error::ClearingPendingCommitError));
255                })
256            })
257            .await
258        }
259    }
260
261    // if there’s no pending commit & and the incoming commit originates from self: succeed by ignoring the incoming commit
262    #[apply(all_cred_cipher)]
263    #[wasm_bindgen_test]
264    pub async fn should_ignore_self_incoming_commit_when_no_pending_commit(case: TestContext) {
265        if !case.is_pure_ciphertext() {
266            run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
267                Box::pin(async move {
268                    let id = conversation_id();
269                    alice_central
270                        .transaction
271                        .new_conversation(&id, case.credential_type, case.cfg.clone())
272                        .await
273                        .unwrap();
274
275                    assert!(alice_central.pending_commit(&id).await.is_none());
276
277                    // create a commit, have it in store...
278                    let commit = alice_central.create_unmerged_commit(&id).await.commit;
279                    assert!(alice_central.pending_commit(&id).await.is_some());
280
281                    // then delete the pending commit
282                    alice_central
283                        .transaction
284                        .conversation(&id)
285                        .await
286                        .unwrap()
287                        .clear_pending_commit()
288                        .await
289                        .unwrap();
290                    assert!(alice_central.pending_commit(&id).await.is_none());
291
292                    let decrypt_self = alice_central
293                        .transaction
294                        .conversation(&id)
295                        .await
296                        .unwrap()
297                        .decrypt_message(&commit.to_bytes().unwrap())
298                        .await;
299                    // this means DS replayed the commit. In that case just ignore, we have already merged the commit anyway
300                    assert!(matches!(decrypt_self.unwrap_err(), Error::SelfCommitIgnored));
301                })
302            })
303            .await
304        }
305    }
306
307    #[apply(all_cred_cipher)]
308    #[wasm_bindgen_test]
309    pub async fn should_fail_when_tampering_with_incoming_own_commit_same_as_pending(case: TestContext) {
310        use crate::MlsErrorKind;
311
312        if case.is_pure_ciphertext() {
313            // The use case tested here requires inspecting your own commit.
314            // Openmls does not support this currently when protocol messages are encrypted.
315            return;
316        }
317        run_test_with_client_ids(case.clone(), ["alice"], move |[alice_central]| {
318            Box::pin(async move {
319                let conversation_id = conversation_id();
320                alice_central
321                    .transaction
322                    .new_conversation(&conversation_id, case.credential_type, case.cfg.clone())
323                    .await
324                    .unwrap();
325
326                // No pending commit yet.
327                assert!(alice_central.pending_commit(&conversation_id).await.is_none());
328
329                // Create the commit that we're going to tamper with.
330                let add_bob_message = alice_central.create_unmerged_commit(&conversation_id).await.commit;
331
332                // Now there is a pending commit.
333                assert!(alice_central.pending_commit(&conversation_id).await.is_some());
334
335                let commit_serialized = &mut add_bob_message.to_bytes().unwrap();
336
337                // Tamper with the commit; this is the signature region, however,
338                // the membership tag covers the signature, so this will result in an
339                // invalid membership tag error emitted by openmls.
340                commit_serialized[355] = commit_serialized[355].wrapping_add(1);
341
342                let decryption_result = alice_central
343                    .transaction
344                    .conversation(&conversation_id)
345                    .await
346                    .unwrap()
347                    .decrypt_message(commit_serialized)
348                    .await;
349                let error = decryption_result.unwrap_err();
350                assert!(matches!(
351                    error,
352                    Error::Mls(MlsError {
353                        source: MlsErrorKind::MlsMessageError(ProcessMessageError::ValidationError(
354                            ValidationError::InvalidMembershipTag
355                        )),
356                        ..
357                    })
358                ));
359
360                // There is still a pending commit.
361                assert!(alice_central.pending_commit(&conversation_id).await.is_some());
362
363                // Positive case: Alice decrypts the commit...
364                assert!(
365                    alice_central
366                        .transaction
367                        .conversation(&conversation_id)
368                        .await
369                        .unwrap()
370                        .decrypt_message(&add_bob_message.to_bytes().unwrap())
371                        .await
372                        .is_ok()
373                );
374
375                // ...and has cleared the pending commit.
376                assert!(alice_central.pending_commit(&conversation_id).await.is_none());
377            })
378        })
379        .await
380    }
381}