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            let [alice_central, bob_central] = case.sessions().await;
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().await.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            .await
122        }
123    }
124
125    #[apply(all_cred_cipher)]
126    #[wasm_bindgen_test]
127    async fn decrypting_duplicate_external_commit_should_fail(case: TestContext) {
128        let [alice_central, bob_central] = case.sessions().await;
129        Box::pin(async move {
130            let id = conversation_id();
131            alice_central
132                .transaction
133                .new_conversation(&id, case.credential_type, case.cfg.clone())
134                .await
135                .unwrap();
136
137            let gi = alice_central.get_group_info(&id).await;
138
139            // an external commit to verify that we can still detect wrong epoch correctly
140            let (unknown_ext_commit, mut pending_conversation) = bob_central
141                .create_unmerged_external_commit(gi.clone(), case.custom_cfg(), case.credential_type)
142                .await;
143            let unknown_ext_commit = unknown_ext_commit.commit;
144            pending_conversation.clear().await.unwrap();
145
146            bob_central
147                .transaction
148                .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
149                .await
150                .unwrap();
151            let ext_commit = bob_central.mls_transport().await.latest_commit().await;
152
153            // decrypt once ... ok
154            alice_central
155                .transaction
156                .conversation(&id)
157                .await
158                .unwrap()
159                .decrypt_message(&ext_commit.to_bytes().unwrap())
160                .await
161                .unwrap();
162            // decrypt twice ... not ok
163            let decryption = alice_central
164                .transaction
165                .conversation(&id)
166                .await
167                .unwrap()
168                .decrypt_message(&ext_commit.to_bytes().unwrap())
169                .await;
170            assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
171
172            // Decrypting unknown external commit.
173            // It fails with this error since it's not the external commit who has created this epoch
174            let decryption = alice_central
175                .transaction
176                .conversation(&id)
177                .await
178                .unwrap()
179                .decrypt_message(&unknown_ext_commit.to_bytes().unwrap())
180                .await;
181            assert!(matches!(decryption.unwrap_err(), Error::StaleCommit));
182        })
183        .await
184    }
185
186    #[apply(all_cred_cipher)]
187    #[wasm_bindgen_test]
188    async fn decrypting_duplicate_proposal_should_fail(case: TestContext) {
189        let [alice_central, bob_central] = case.sessions().await;
190        Box::pin(async move {
191            let id = conversation_id();
192            alice_central
193                .transaction
194                .new_conversation(&id, case.credential_type, case.cfg.clone())
195                .await
196                .unwrap();
197            alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
198
199            let proposal = alice_central
200                .transaction
201                .new_update_proposal(&id)
202                .await
203                .unwrap()
204                .proposal;
205
206            // decrypt once ... ok
207            bob_central
208                .transaction
209                .conversation(&id)
210                .await
211                .unwrap()
212                .decrypt_message(&proposal.to_bytes().unwrap())
213                .await
214                .unwrap();
215
216            // decrypt twice ... not ok
217            let decryption = bob_central
218                .transaction
219                .conversation(&id)
220                .await
221                .unwrap()
222                .decrypt_message(&proposal.to_bytes().unwrap())
223                .await;
224            assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
225
226            // advance Bob's epoch to trigger failure
227            bob_central
228                .transaction
229                .conversation(&id)
230                .await
231                .unwrap()
232                .commit_pending_proposals()
233                .await
234                .unwrap();
235
236            // Epoch has advanced so we cannot detect duplicates anymore
237            let decryption = bob_central
238                .transaction
239                .conversation(&id)
240                .await
241                .unwrap()
242                .decrypt_message(&proposal.to_bytes().unwrap())
243                .await;
244            assert!(matches!(decryption.unwrap_err(), Error::StaleProposal));
245        })
246        .await
247    }
248
249    #[apply(all_cred_cipher)]
250    #[wasm_bindgen_test]
251    async fn decrypting_duplicate_external_proposal_should_fail(case: TestContext) {
252        let [alice_central, bob_central] = case.sessions().await;
253        Box::pin(async move {
254            let id = conversation_id();
255            alice_central
256                .transaction
257                .new_conversation(&id, case.credential_type, case.cfg.clone())
258                .await
259                .unwrap();
260
261            let epoch = alice_central.transaction.conversation(&id).await.unwrap().epoch().await;
262
263            let ext_proposal = bob_central
264                .transaction
265                .new_external_add_proposal(id.clone(), epoch.into(), case.ciphersuite(), case.credential_type)
266                .await
267                .unwrap();
268
269            // decrypt once ... ok
270            alice_central
271                .transaction
272                .conversation(&id)
273                .await
274                .unwrap()
275                .decrypt_message(&ext_proposal.to_bytes().unwrap())
276                .await
277                .unwrap();
278
279            // decrypt twice ... not ok
280            let decryption = alice_central
281                .transaction
282                .conversation(&id)
283                .await
284                .unwrap()
285                .decrypt_message(&ext_proposal.to_bytes().unwrap())
286                .await;
287            assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
288
289            // advance alice's epoch
290            alice_central
291                .transaction
292                .conversation(&id)
293                .await
294                .unwrap()
295                .commit_pending_proposals()
296                .await
297                .unwrap();
298
299            // Epoch has advanced so we cannot detect duplicates anymore
300            let decryption = alice_central
301                .transaction
302                .conversation(&id)
303                .await
304                .unwrap()
305                .decrypt_message(&ext_proposal.to_bytes().unwrap())
306                .await;
307            assert!(matches!(decryption.unwrap_err(), Error::StaleProposal));
308        })
309        .await
310    }
311
312    // Ensures decrypting an application message is durable (we increment the messages generation & persist the group)
313    #[apply(all_cred_cipher)]
314    #[wasm_bindgen_test]
315    async fn decrypting_duplicate_application_message_should_fail(case: TestContext) {
316        let [alice_central, bob_central] = case.sessions().await;
317        Box::pin(async move {
318            let id = conversation_id();
319            alice_central
320                .transaction
321                .new_conversation(&id, case.credential_type, case.cfg.clone())
322                .await
323                .unwrap();
324            alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
325
326            let msg = b"Hello bob";
327            let encrypted = alice_central
328                .transaction
329                .conversation(&id)
330                .await
331                .unwrap()
332                .encrypt_message(msg)
333                .await
334                .unwrap();
335
336            // decrypt once .. ok
337            bob_central
338                .transaction
339                .conversation(&id)
340                .await
341                .unwrap()
342                .decrypt_message(&encrypted)
343                .await
344                .unwrap();
345            // decrypt twice .. not ok
346            let decryption = bob_central
347                .transaction
348                .conversation(&id)
349                .await
350                .unwrap()
351                .decrypt_message(&encrypted)
352                .await;
353            assert!(matches!(decryption.unwrap_err(), Error::DuplicateMessage));
354        })
355        .await
356    }
357}