core_crypto/mls/conversation/
own_commit.rs

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