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 crate::prelude::MlsConversation;
6use crate::{CryptoError, MlsError};
7use mls_crypto_provider::MlsCryptoProvider;
8use openmls::prelude::{ContentType, FramedContentBodyIn, Proposal, PublicMessageIn, Sender};
9
10impl MlsConversation {
11    pub(crate) fn is_duplicate_message(
12        &self,
13        backend: &MlsCryptoProvider,
14        msg: &PublicMessageIn,
15    ) -> Result<bool, CryptoError> {
16        let (sender, content_type) = (msg.sender(), msg.body().content_type());
17
18        match (content_type, sender) {
19            (ContentType::Commit, Sender::Member(_) | Sender::NewMemberCommit) => {
20                // we use the confirmation tag to detect duplicate since it is issued from the GroupContext
21                // which is supposed to be unique per epoch
22                if let Some(msg_ct) = msg.confirmation_tag() {
23                    let group_ct = self.group.compute_confirmation_tag(backend).map_err(MlsError::from)?;
24                    Ok(msg_ct == &group_ct)
25                } else {
26                    // a commit MUST have a ConfirmationTag
27                    Err(CryptoError::InternalMlsError)
28                }
29            }
30            (ContentType::Proposal, Sender::Member(_) | Sender::NewMemberProposal) => {
31                match msg.body() {
32                    FramedContentBodyIn::Proposal(proposal) => {
33                        let proposal = Proposal::from(proposal.clone()); // TODO: eventually remove this clone 😮‍💨. Tracking issue: WPB-9622
34                        let already_exists = self.group.pending_proposals().any(|pp| pp.proposal() == &proposal);
35                        Ok(already_exists)
36                    }
37                    _ => Err(CryptoError::InternalMlsError),
38                }
39            }
40            (_, _) => Ok(false),
41        }
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use crate::{test_utils::*, CryptoError};
48    use wasm_bindgen_test::*;
49
50    wasm_bindgen_test_configure!(run_in_browser);
51
52    #[apply(all_cred_cipher)]
53    #[wasm_bindgen_test]
54    async fn decrypting_duplicate_member_commit_should_fail(case: TestCase) {
55        // cannot work in pure ciphertext since we'd have to decrypt the message first
56        if !case.is_pure_ciphertext() {
57            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
58                Box::pin(async move {
59                    let id = conversation_id();
60                    alice_central
61                        .context
62                        .new_conversation(&id, case.credential_type, case.cfg.clone())
63                        .await
64                        .unwrap();
65                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
66
67                    // an commit to verify that we can still detect wrong epoch correctly
68                    let unknown_commit = alice_central.context.update_keying_material(&id).await.unwrap().commit;
69                    alice_central.context.clear_pending_commit(&id).await.unwrap();
70
71                    let commit = alice_central.context.update_keying_material(&id).await.unwrap().commit;
72                    alice_central.context.commit_accepted(&id).await.unwrap();
73
74                    // decrypt once ... ok
75                    bob_central
76                        .context
77                        .decrypt_message(&id, &commit.to_bytes().unwrap())
78                        .await
79                        .unwrap();
80                    // decrypt twice ... not ok
81                    let decrypt_duplicate = bob_central
82                        .context
83                        .decrypt_message(&id, &commit.to_bytes().unwrap())
84                        .await;
85                    assert!(matches!(decrypt_duplicate.unwrap_err(), CryptoError::DuplicateMessage));
86
87                    // Decrypting unknown commit.
88                    // It fails with this error since it's not the commit who has created this epoch
89                    let decrypt_lost_commit = bob_central
90                        .context
91                        .decrypt_message(&id, &unknown_commit.to_bytes().unwrap())
92                        .await;
93                    assert!(matches!(decrypt_lost_commit.unwrap_err(), CryptoError::StaleCommit));
94                })
95            })
96            .await
97        }
98    }
99
100    #[apply(all_cred_cipher)]
101    #[wasm_bindgen_test]
102    async fn decrypting_duplicate_external_commit_should_fail(case: TestCase) {
103        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
104            Box::pin(async move {
105                let id = conversation_id();
106                alice_central
107                    .context
108                    .new_conversation(&id, case.credential_type, case.cfg.clone())
109                    .await
110                    .unwrap();
111
112                let gi = alice_central.get_group_info(&id).await;
113
114                // an external commit to verify that we can still detect wrong epoch correctly
115                let unknown_ext_commit = bob_central
116                    .context
117                    .join_by_external_commit(gi.clone(), case.custom_cfg(), case.credential_type)
118                    .await
119                    .unwrap()
120                    .commit;
121                bob_central
122                    .context
123                    .clear_pending_group_from_external_commit(&id)
124                    .await
125                    .unwrap();
126
127                let ext_commit = bob_central
128                    .context
129                    .join_by_external_commit(gi, case.custom_cfg(), case.credential_type)
130                    .await
131                    .unwrap()
132                    .commit;
133                bob_central
134                    .context
135                    .merge_pending_group_from_external_commit(&id)
136                    .await
137                    .unwrap();
138
139                // decrypt once ... ok
140                alice_central
141                    .context
142                    .decrypt_message(&id, &ext_commit.to_bytes().unwrap())
143                    .await
144                    .unwrap();
145                // decrypt twice ... not ok
146                let decryption = alice_central
147                    .context
148                    .decrypt_message(&id, &ext_commit.to_bytes().unwrap())
149                    .await;
150                assert!(matches!(decryption.unwrap_err(), CryptoError::DuplicateMessage));
151
152                // Decrypting unknown external commit.
153                // It fails with this error since it's not the external commit who has created this epoch
154                let decryption = alice_central
155                    .context
156                    .decrypt_message(&id, &unknown_ext_commit.to_bytes().unwrap())
157                    .await;
158                assert!(matches!(decryption.unwrap_err(), CryptoError::StaleCommit));
159            })
160        })
161        .await
162    }
163
164    #[apply(all_cred_cipher)]
165    #[wasm_bindgen_test]
166    async fn decrypting_duplicate_proposal_should_fail(case: TestCase) {
167        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
168            Box::pin(async move {
169                let id = conversation_id();
170                alice_central
171                    .context
172                    .new_conversation(&id, case.credential_type, case.cfg.clone())
173                    .await
174                    .unwrap();
175                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
176
177                let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
178
179                // decrypt once ... ok
180                bob_central
181                    .context
182                    .decrypt_message(&id, &proposal.to_bytes().unwrap())
183                    .await
184                    .unwrap();
185
186                // decrypt twice ... not ok
187                let decryption = bob_central
188                    .context
189                    .decrypt_message(&id, &proposal.to_bytes().unwrap())
190                    .await;
191                assert!(matches!(decryption.unwrap_err(), CryptoError::DuplicateMessage));
192
193                // advance Bob's epoch to trigger failure
194                bob_central.context.commit_pending_proposals(&id).await.unwrap();
195                bob_central.context.commit_accepted(&id).await.unwrap();
196
197                // Epoch has advanced so we cannot detect duplicates anymore
198                let decryption = bob_central
199                    .context
200                    .decrypt_message(&id, &proposal.to_bytes().unwrap())
201                    .await;
202                assert!(matches!(decryption.unwrap_err(), CryptoError::StaleProposal));
203            })
204        })
205        .await
206    }
207
208    #[apply(all_cred_cipher)]
209    #[wasm_bindgen_test]
210    async fn decrypting_duplicate_external_proposal_should_fail(case: TestCase) {
211        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
212            Box::pin(async move {
213                let id = conversation_id();
214                alice_central
215                    .context
216                    .new_conversation(&id, case.credential_type, case.cfg.clone())
217                    .await
218                    .unwrap();
219
220                let epoch = alice_central.context.conversation_epoch(&id).await.unwrap();
221
222                let ext_proposal = bob_central
223                    .context
224                    .new_external_add_proposal(id.clone(), epoch.into(), case.ciphersuite(), case.credential_type)
225                    .await
226                    .unwrap();
227
228                // decrypt once ... ok
229                alice_central
230                    .context
231                    .decrypt_message(&id, &ext_proposal.to_bytes().unwrap())
232                    .await
233                    .unwrap();
234
235                // decrypt twice ... not ok
236                let decryption = alice_central
237                    .context
238                    .decrypt_message(&id, &ext_proposal.to_bytes().unwrap())
239                    .await;
240                assert!(matches!(decryption.unwrap_err(), CryptoError::DuplicateMessage));
241
242                // advance alice's epoch
243                alice_central.context.commit_pending_proposals(&id).await.unwrap();
244                alice_central.context.commit_accepted(&id).await.unwrap();
245
246                // Epoch has advanced so we cannot detect duplicates anymore
247                let decryption = alice_central
248                    .context
249                    .decrypt_message(&id, &ext_proposal.to_bytes().unwrap())
250                    .await;
251                assert!(matches!(decryption.unwrap_err(), CryptoError::StaleProposal));
252            })
253        })
254        .await
255    }
256
257    // Ensures decrypting an application message is durable (we increment the messages generation & persist the group)
258    #[apply(all_cred_cipher)]
259    #[wasm_bindgen_test]
260    async fn decrypting_duplicate_application_message_should_fail(case: TestCase) {
261        run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
262            Box::pin(async move {
263                let id = conversation_id();
264                alice_central
265                    .context
266                    .new_conversation(&id, case.credential_type, case.cfg.clone())
267                    .await
268                    .unwrap();
269                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
270
271                let msg = b"Hello bob";
272                let encrypted = alice_central.context.encrypt_message(&id, msg).await.unwrap();
273
274                // decrypt once .. ok
275                bob_central.context.decrypt_message(&id, &encrypted).await.unwrap();
276                // decrypt twice .. not ok
277                let decryption = bob_central.context.decrypt_message(&id, &encrypted).await;
278                assert!(matches!(decryption.unwrap_err(), CryptoError::DuplicateMessage));
279            })
280        })
281        .await
282    }
283}