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: TestContext) {
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                        .transaction
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                        .transaction
74                        .conversation(&id)
75                        .await
76                        .unwrap()
77                        .clear_pending_commit()
78                        .await
79                        .unwrap();
80
81                    alice_central
82                        .transaction
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                        .transaction
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                        .transaction
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                        .transaction
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: TestContext) {
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                    .transaction
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                    .transaction
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                    .transaction
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                    .transaction
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                    .transaction
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: TestContext) {
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                    .transaction
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
202                    .transaction
203                    .new_update_proposal(&id)
204                    .await
205                    .unwrap()
206                    .proposal;
207
208                // decrypt once ... ok
209                bob_central
210                    .transaction
211                    .conversation(&id)
212                    .await
213                    .unwrap()
214                    .decrypt_message(&proposal.to_bytes().unwrap())
215                    .await
216                    .unwrap();
217
218                // decrypt twice ... not ok
219                let decryption = bob_central
220                    .transaction
221                    .conversation(&id)
222                    .await
223                    .unwrap()
224                    .decrypt_message(&proposal.to_bytes().unwrap())
225                    .await;
226                assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
227
228                // advance Bob's epoch to trigger failure
229                bob_central
230                    .transaction
231                    .conversation(&id)
232                    .await
233                    .unwrap()
234                    .commit_pending_proposals()
235                    .await
236                    .unwrap();
237
238                // Epoch has advanced so we cannot detect duplicates anymore
239                let decryption = bob_central
240                    .transaction
241                    .conversation(&id)
242                    .await
243                    .unwrap()
244                    .decrypt_message(&proposal.to_bytes().unwrap())
245                    .await;
246                assert!(matches!(decryption.unwrap_err(), Error::StaleProposal));
247            })
248        })
249        .await
250    }
251
252    #[apply(all_cred_cipher)]
253    #[wasm_bindgen_test]
254    async fn decrypting_duplicate_external_proposal_should_fail(case: TestContext) {
255        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
256            Box::pin(async move {
257                let id = conversation_id();
258                alice_central
259                    .transaction
260                    .new_conversation(&id, case.credential_type, case.cfg.clone())
261                    .await
262                    .unwrap();
263
264                let epoch = alice_central.transaction.conversation(&id).await.unwrap().epoch().await;
265
266                let ext_proposal = bob_central
267                    .transaction
268                    .new_external_add_proposal(id.clone(), epoch.into(), case.ciphersuite(), case.credential_type)
269                    .await
270                    .unwrap();
271
272                // decrypt once ... ok
273                alice_central
274                    .transaction
275                    .conversation(&id)
276                    .await
277                    .unwrap()
278                    .decrypt_message(&ext_proposal.to_bytes().unwrap())
279                    .await
280                    .unwrap();
281
282                // decrypt twice ... not ok
283                let decryption = alice_central
284                    .transaction
285                    .conversation(&id)
286                    .await
287                    .unwrap()
288                    .decrypt_message(&ext_proposal.to_bytes().unwrap())
289                    .await;
290                assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
291
292                // advance alice's epoch
293                alice_central
294                    .transaction
295                    .conversation(&id)
296                    .await
297                    .unwrap()
298                    .commit_pending_proposals()
299                    .await
300                    .unwrap();
301
302                // Epoch has advanced so we cannot detect duplicates anymore
303                let decryption = alice_central
304                    .transaction
305                    .conversation(&id)
306                    .await
307                    .unwrap()
308                    .decrypt_message(&ext_proposal.to_bytes().unwrap())
309                    .await;
310                assert!(matches!(decryption.unwrap_err(), Error::StaleProposal));
311            })
312        })
313        .await
314    }
315
316    // Ensures decrypting an application message is durable (we increment the messages generation & persist the group)
317    #[apply(all_cred_cipher)]
318    #[wasm_bindgen_test]
319    async fn decrypting_duplicate_application_message_should_fail(case: TestContext) {
320        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
321            Box::pin(async move {
322                let id = conversation_id();
323                alice_central
324                    .transaction
325                    .new_conversation(&id, case.credential_type, case.cfg.clone())
326                    .await
327                    .unwrap();
328                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
329
330                let msg = b"Hello bob";
331                let encrypted = alice_central
332                    .transaction
333                    .conversation(&id)
334                    .await
335                    .unwrap()
336                    .encrypt_message(msg)
337                    .await
338                    .unwrap();
339
340                // decrypt once .. ok
341                bob_central
342                    .transaction
343                    .conversation(&id)
344                    .await
345                    .unwrap()
346                    .decrypt_message(&encrypted)
347                    .await
348                    .unwrap();
349                // decrypt twice .. not ok
350                let decryption = bob_central
351                    .transaction
352                    .conversation(&id)
353                    .await
354                    .unwrap()
355                    .decrypt_message(&encrypted)
356                    .await;
357                assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
358            })
359        })
360        .await
361    }
362}