core_crypto/mls/conversation/
duplicate.rs

1//! Due the current delivery semantics on backend side (at least once) we have to deal with this
2//! in CoreCrypto so as not to return a decryption error to the client. Remove this when this is used
3//! with a DS guaranteeing exactly once delivery semantics since the following degrades the performances
4
5use super::{Error, Result};
6use crate::{MlsError, prelude::MlsConversation};
7use mls_crypto_provider::MlsCryptoProvider;
8use openmls::prelude::{ContentType, FramedContentBodyIn, Proposal, PublicMessageIn, Sender};
9
10impl MlsConversation {
11    pub(crate) fn is_duplicate_message(&self, backend: &MlsCryptoProvider, msg: &PublicMessageIn) -> Result<bool> {
12        let (sender, content_type) = (msg.sender(), msg.body().content_type());
13
14        match (content_type, sender) {
15            (ContentType::Commit, Sender::Member(_) | Sender::NewMemberCommit) => {
16                // we use the confirmation tag to detect duplicate since it is issued from the GroupContext
17                // which is supposed to be unique per epoch
18                if let Some(msg_ct) = msg.confirmation_tag() {
19                    let group_ct = self
20                        .group
21                        .compute_confirmation_tag(backend)
22                        .map_err(MlsError::wrap("computing confirmation tag"))?;
23                    Ok(msg_ct == &group_ct)
24                } else {
25                    // a commit MUST have a ConfirmationTag
26                    Err(Error::MlsGroupInvalidState("a commit must have a ConfirmationTag"))
27                }
28            }
29            (ContentType::Proposal, Sender::Member(_) | Sender::NewMemberProposal) => {
30                match msg.body() {
31                    FramedContentBodyIn::Proposal(proposal) => {
32                        let proposal = Proposal::from(proposal.clone()); // TODO: eventually remove this clone 😮‍💨. Tracking issue: WPB-9622
33                        let already_exists = self.group.pending_proposals().any(|pp| pp.proposal() == &proposal);
34                        Ok(already_exists)
35                    }
36                    _ => Err(Error::MlsGroupInvalidState(
37                        "message body was not a proposal despite ContentType::Proposal",
38                    )),
39                }
40            }
41            (_, _) => Ok(false),
42        }
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::super::error::Error;
49    use crate::mls::conversation::Conversation as _;
50    use crate::test_utils::*;
51    use wasm_bindgen_test::*;
52
53    wasm_bindgen_test_configure!(run_in_browser);
54
55    #[apply(all_cred_cipher)]
56    #[wasm_bindgen_test]
57    async fn decrypting_duplicate_member_commit_should_fail(case: TestCase) {
58        // cannot work in pure ciphertext since we'd have to decrypt the message first
59        if !case.is_pure_ciphertext() {
60            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
61                Box::pin(async move {
62                    let id = conversation_id();
63                    alice_central
64                        .context
65                        .new_conversation(&id, case.credential_type, case.cfg.clone())
66                        .await
67                        .unwrap();
68                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
69
70                    // an commit to verify that we can still detect wrong epoch correctly
71                    let unknown_commit = alice_central.create_unmerged_commit(&id).await.commit;
72                    alice_central
73                        .context
74                        .conversation(&id)
75                        .await
76                        .unwrap()
77                        .clear_pending_commit()
78                        .await
79                        .unwrap();
80
81                    alice_central
82                        .context
83                        .conversation(&id)
84                        .await
85                        .unwrap()
86                        .update_key_material()
87                        .await
88                        .unwrap();
89                    let commit = alice_central.mls_transport.latest_commit().await;
90
91                    // decrypt once ... ok
92                    bob_central
93                        .context
94                        .conversation(&id)
95                        .await
96                        .unwrap()
97                        .decrypt_message(&commit.to_bytes().unwrap())
98                        .await
99                        .unwrap();
100                    // decrypt twice ... not ok
101                    let decrypt_duplicate = bob_central
102                        .context
103                        .conversation(&id)
104                        .await
105                        .unwrap()
106                        .decrypt_message(&commit.to_bytes().unwrap())
107                        .await;
108                    assert!(matches!(decrypt_duplicate.unwrap_err(), Error::DuplicateMessage));
109
110                    // Decrypting unknown commit.
111                    // It fails with this error since it's not the commit who has created this epoch
112                    let decrypt_lost_commit = bob_central
113                        .context
114                        .conversation(&id)
115                        .await
116                        .unwrap()
117                        .decrypt_message(&unknown_commit.to_bytes().unwrap())
118                        .await;
119                    assert!(matches!(decrypt_lost_commit.unwrap_err(), Error::StaleCommit));
120                })
121            })
122            .await
123        }
124    }
125
126    #[apply(all_cred_cipher)]
127    #[wasm_bindgen_test]
128    async fn decrypting_duplicate_external_commit_should_fail(case: TestCase) {
129        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
130            Box::pin(async move {
131                let id = conversation_id();
132                alice_central
133                    .context
134                    .new_conversation(&id, case.credential_type, case.cfg.clone())
135                    .await
136                    .unwrap();
137
138                let gi = alice_central.get_group_info(&id).await;
139
140                // an external commit to verify that we can still detect wrong epoch correctly
141                let (unknown_ext_commit, mut pending_conversation) = bob_central
142                    .create_unmerged_external_commit(gi.clone(), case.custom_cfg(), case.credential_type)
143                    .await;
144                let unknown_ext_commit = unknown_ext_commit.commit;
145                pending_conversation.clear().await.unwrap();
146
147                bob_central
148                    .context
149                    .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
150                    .await
151                    .unwrap();
152                let ext_commit = bob_central.mls_transport.latest_commit().await;
153
154                // decrypt once ... ok
155                alice_central
156                    .context
157                    .conversation(&id)
158                    .await
159                    .unwrap()
160                    .decrypt_message(&ext_commit.to_bytes().unwrap())
161                    .await
162                    .unwrap();
163                // decrypt twice ... not ok
164                let decryption = alice_central
165                    .context
166                    .conversation(&id)
167                    .await
168                    .unwrap()
169                    .decrypt_message(&ext_commit.to_bytes().unwrap())
170                    .await;
171                assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
172
173                // Decrypting unknown external commit.
174                // It fails with this error since it's not the external commit who has created this epoch
175                let decryption = alice_central
176                    .context
177                    .conversation(&id)
178                    .await
179                    .unwrap()
180                    .decrypt_message(&unknown_ext_commit.to_bytes().unwrap())
181                    .await;
182                assert!(matches!(decryption.unwrap_err(), Error::StaleCommit));
183            })
184        })
185        .await
186    }
187
188    #[apply(all_cred_cipher)]
189    #[wasm_bindgen_test]
190    async fn decrypting_duplicate_proposal_should_fail(case: TestCase) {
191        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
192            Box::pin(async move {
193                let id = conversation_id();
194                alice_central
195                    .context
196                    .new_conversation(&id, case.credential_type, case.cfg.clone())
197                    .await
198                    .unwrap();
199                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
200
201                let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
202
203                // decrypt once ... ok
204                bob_central
205                    .context
206                    .conversation(&id)
207                    .await
208                    .unwrap()
209                    .decrypt_message(&proposal.to_bytes().unwrap())
210                    .await
211                    .unwrap();
212
213                // decrypt twice ... not ok
214                let decryption = bob_central
215                    .context
216                    .conversation(&id)
217                    .await
218                    .unwrap()
219                    .decrypt_message(&proposal.to_bytes().unwrap())
220                    .await;
221                assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
222
223                // advance Bob's epoch to trigger failure
224                bob_central
225                    .context
226                    .conversation(&id)
227                    .await
228                    .unwrap()
229                    .commit_pending_proposals()
230                    .await
231                    .unwrap();
232
233                // Epoch has advanced so we cannot detect duplicates anymore
234                let decryption = bob_central
235                    .context
236                    .conversation(&id)
237                    .await
238                    .unwrap()
239                    .decrypt_message(&proposal.to_bytes().unwrap())
240                    .await;
241                assert!(matches!(decryption.unwrap_err(), Error::StaleProposal));
242            })
243        })
244        .await
245    }
246
247    #[apply(all_cred_cipher)]
248    #[wasm_bindgen_test]
249    async fn decrypting_duplicate_external_proposal_should_fail(case: TestCase) {
250        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
251            Box::pin(async move {
252                let id = conversation_id();
253                alice_central
254                    .context
255                    .new_conversation(&id, case.credential_type, case.cfg.clone())
256                    .await
257                    .unwrap();
258
259                let epoch = alice_central.context.conversation(&id).await.unwrap().epoch().await;
260
261                let ext_proposal = bob_central
262                    .context
263                    .new_external_add_proposal(id.clone(), epoch.into(), case.ciphersuite(), case.credential_type)
264                    .await
265                    .unwrap();
266
267                // decrypt once ... ok
268                alice_central
269                    .context
270                    .conversation(&id)
271                    .await
272                    .unwrap()
273                    .decrypt_message(&ext_proposal.to_bytes().unwrap())
274                    .await
275                    .unwrap();
276
277                // decrypt twice ... not ok
278                let decryption = alice_central
279                    .context
280                    .conversation(&id)
281                    .await
282                    .unwrap()
283                    .decrypt_message(&ext_proposal.to_bytes().unwrap())
284                    .await;
285                assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
286
287                // advance alice's epoch
288                alice_central
289                    .context
290                    .conversation(&id)
291                    .await
292                    .unwrap()
293                    .commit_pending_proposals()
294                    .await
295                    .unwrap();
296
297                // Epoch has advanced so we cannot detect duplicates anymore
298                let decryption = alice_central
299                    .context
300                    .conversation(&id)
301                    .await
302                    .unwrap()
303                    .decrypt_message(&ext_proposal.to_bytes().unwrap())
304                    .await;
305                assert!(matches!(decryption.unwrap_err(), Error::StaleProposal));
306            })
307        })
308        .await
309    }
310
311    // Ensures decrypting an application message is durable (we increment the messages generation & persist the group)
312    #[apply(all_cred_cipher)]
313    #[wasm_bindgen_test]
314    async fn decrypting_duplicate_application_message_should_fail(case: TestCase) {
315        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
316            Box::pin(async move {
317                let id = conversation_id();
318                alice_central
319                    .context
320                    .new_conversation(&id, case.credential_type, case.cfg.clone())
321                    .await
322                    .unwrap();
323                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
324
325                let msg = b"Hello bob";
326                let encrypted = alice_central
327                    .context
328                    .conversation(&id)
329                    .await
330                    .unwrap()
331                    .encrypt_message(msg)
332                    .await
333                    .unwrap();
334
335                // decrypt once .. ok
336                bob_central
337                    .context
338                    .conversation(&id)
339                    .await
340                    .unwrap()
341                    .decrypt_message(&encrypted)
342                    .await
343                    .unwrap();
344                // decrypt twice .. not ok
345                let decryption = bob_central
346                    .context
347                    .conversation(&id)
348                    .await
349                    .unwrap()
350                    .decrypt_message(&encrypted)
351                    .await;
352                assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
353            })
354        })
355        .await
356    }
357}