core_crypto/mls/conversation/
proposal.rs

1//! This table summarizes when a MLS group can create a commit or proposal:
2//!
3//! | can create handshake ? | 0 pend. Commit | 1 pend. Commit |
4//! |------------------------|----------------|----------------|
5//! | 0 pend. Proposal       | ✅              | ❌              |
6//! | 1+ pend. Proposal      | ✅              | ❌              |
7
8use openmls::{binary_tree::LeafNodeIndex, framing::MlsMessageOut, key_packages::KeyPackageIn, prelude::LeafNode};
9
10use mls_crypto_provider::MlsCryptoProvider;
11
12use crate::{
13    e2e_identity::init_certificates::NewCrlDistributionPoint, mls::credential::crl::get_new_crl_distribution_points,
14};
15use crate::{
16    mls::credential::crl::extract_crl_uris_from_credentials,
17    prelude::{Client, MlsConversation, MlsProposalRef},
18    CryptoError, CryptoResult, MlsError,
19};
20
21/// Creating proposals
22impl MlsConversation {
23    /// see [openmls::group::MlsGroup::propose_add_member]
24    #[cfg_attr(test, crate::durable)]
25    pub async fn propose_add_member(
26        &mut self,
27        client: &Client,
28        backend: &MlsCryptoProvider,
29        key_package: KeyPackageIn,
30    ) -> CryptoResult<MlsProposalBundle> {
31        let signer = &self
32            .find_current_credential_bundle(client)
33            .await
34            .map_err(|_| CryptoError::IdentityInitializationError)?
35            .signature_key;
36
37        let crl_new_distribution_points = get_new_crl_distribution_points(
38            backend,
39            extract_crl_uris_from_credentials(std::iter::once(key_package.credential().mls_credential()))?,
40        )
41        .await?;
42
43        let (proposal, proposal_ref) = self
44            .group
45            .propose_add_member(backend, signer, key_package)
46            .await
47            .map_err(MlsError::from)?;
48        let proposal = MlsProposalBundle {
49            proposal,
50            proposal_ref: proposal_ref.into(),
51            crl_new_distribution_points,
52        };
53        self.persist_group_when_changed(&backend.keystore(), false).await?;
54        Ok(proposal)
55    }
56
57    /// see [openmls::group::MlsGroup::propose_remove_member]
58    #[cfg_attr(test, crate::durable)]
59    pub async fn propose_remove_member(
60        &mut self,
61        client: &Client,
62        backend: &MlsCryptoProvider,
63        member: LeafNodeIndex,
64    ) -> CryptoResult<MlsProposalBundle> {
65        let signer = &self
66            .find_current_credential_bundle(client)
67            .await
68            .map_err(|_| CryptoError::IdentityInitializationError)?
69            .signature_key;
70        let proposal = self
71            .group
72            .propose_remove_member(backend, signer, member)
73            .map_err(MlsError::from)
74            .map_err(CryptoError::from)
75            .map(MlsProposalBundle::from)?;
76        self.persist_group_when_changed(&backend.keystore(), false).await?;
77        Ok(proposal)
78    }
79
80    /// see [openmls::group::MlsGroup::propose_self_update]
81    #[cfg_attr(test, crate::durable)]
82    pub async fn propose_self_update(
83        &mut self,
84        client: &Client,
85        backend: &MlsCryptoProvider,
86    ) -> CryptoResult<MlsProposalBundle> {
87        self.propose_explicit_self_update(client, backend, None).await
88    }
89
90    /// see [openmls::group::MlsGroup::propose_self_update]
91    #[cfg_attr(test, crate::durable)]
92    pub async fn propose_explicit_self_update(
93        &mut self,
94        client: &Client,
95        backend: &MlsCryptoProvider,
96        leaf_node: Option<LeafNode>,
97    ) -> CryptoResult<MlsProposalBundle> {
98        let msg_signer = &self
99            .find_current_credential_bundle(client)
100            .await
101            .map_err(|_| CryptoError::IdentityInitializationError)?
102            .signature_key;
103
104        let proposal = if let Some(leaf_node) = leaf_node {
105            let leaf_node_signer = &self.find_most_recent_credential_bundle(client).await?.signature_key;
106
107            self.group
108                .propose_explicit_self_update(backend, msg_signer, leaf_node, leaf_node_signer)
109                .await
110        } else {
111            self.group.propose_self_update(backend, msg_signer).await
112        }
113        .map_err(MlsError::from)
114        .map(MlsProposalBundle::from)?;
115
116        self.persist_group_when_changed(&backend.keystore(), false).await?;
117        Ok(proposal)
118    }
119}
120
121/// Returned when a Proposal is created. Helps roll backing a local proposal
122#[derive(Debug)]
123pub struct MlsProposalBundle {
124    /// The proposal message
125    pub proposal: MlsMessageOut,
126    /// A unique identifier of the proposal to rollback it later if required
127    pub proposal_ref: MlsProposalRef,
128    /// New CRL distribution points that appeared by the introduction of a new credential
129    pub crl_new_distribution_points: NewCrlDistributionPoint,
130}
131
132impl From<(MlsMessageOut, openmls::prelude::hash_ref::ProposalRef)> for MlsProposalBundle {
133    fn from((proposal, proposal_ref): (MlsMessageOut, openmls::prelude::hash_ref::ProposalRef)) -> Self {
134        Self {
135            proposal,
136            proposal_ref: proposal_ref.into(),
137            crl_new_distribution_points: None.into(),
138        }
139    }
140}
141
142impl MlsProposalBundle {
143    /// Serializes both wrapped objects into TLS and return them as a tuple of byte arrays.
144    /// 0 -> proposal
145    /// 1 -> proposal reference
146    #[allow(clippy::type_complexity)]
147    pub fn to_bytes(self) -> CryptoResult<(Vec<u8>, Vec<u8>, NewCrlDistributionPoint)> {
148        use openmls::prelude::TlsSerializeTrait as _;
149        let proposal = self.proposal.tls_serialize_detached().map_err(MlsError::from)?;
150        let proposal_ref = self.proposal_ref.to_bytes();
151
152        Ok((proposal, proposal_ref, self.crl_new_distribution_points))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use itertools::Itertools;
159    use openmls::prelude::SignaturePublicKey;
160    use wasm_bindgen_test::*;
161
162    use crate::{prelude::MlsCommitBundle, test_utils::*};
163
164    use super::*;
165
166    wasm_bindgen_test_configure!(run_in_browser);
167
168    mod propose_add_members {
169        use super::*;
170
171        #[apply(all_cred_cipher)]
172        #[wasm_bindgen_test]
173        async fn can_propose_adding_members_to_conversation(case: TestCase) {
174            run_test_with_client_ids(
175                case.clone(),
176                ["alice", "bob", "charlie"],
177                move |[mut alice_central, bob_central, mut charlie_central]| {
178                    Box::pin(async move {
179                        let id = conversation_id();
180                        alice_central
181                            .context
182                            .new_conversation(&id, case.credential_type, case.cfg.clone())
183                            .await
184                            .unwrap();
185                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
186                        let charlie_kp = charlie_central.get_one_key_package(&case).await;
187
188                        assert!(alice_central.pending_proposals(&id).await.is_empty());
189                        let proposal = alice_central
190                            .context
191                            .new_add_proposal(&id, charlie_kp)
192                            .await
193                            .unwrap()
194                            .proposal;
195                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
196                        bob_central
197                            .context
198                            .decrypt_message(&id, proposal.to_bytes().unwrap())
199                            .await
200                            .unwrap();
201                        let MlsCommitBundle { commit, welcome, .. } = bob_central
202                            .context
203                            .commit_pending_proposals(&id)
204                            .await
205                            .unwrap()
206                            .unwrap();
207                        bob_central.context.commit_accepted(&id).await.unwrap();
208                        assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
209
210                        // if 'new_proposal' wasn't durable this would fail because proposal would
211                        // not be referenced in commit
212                        alice_central
213                            .context
214                            .decrypt_message(&id, commit.to_bytes().unwrap())
215                            .await
216                            .unwrap();
217                        assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
218
219                        charlie_central
220                            .try_join_from_welcome(
221                                &id,
222                                welcome.unwrap().into(),
223                                case.custom_cfg(),
224                                vec![&alice_central, &bob_central],
225                            )
226                            .await
227                            .unwrap();
228                        assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
229                    })
230                },
231            )
232            .await
233        }
234    }
235
236    mod propose_remove_members {
237        use super::*;
238
239        #[apply(all_cred_cipher)]
240        #[wasm_bindgen_test]
241        async fn can_propose_removing_members_from_conversation(case: TestCase) {
242            run_test_with_client_ids(
243                case.clone(),
244                ["alice", "bob", "charlie"],
245                move |[mut alice_central, bob_central, charlie_central]| {
246                    Box::pin(async move {
247                        let id = conversation_id();
248                        alice_central
249                            .context
250                            .new_conversation(&id, case.credential_type, case.cfg.clone())
251                            .await
252                            .unwrap();
253                        alice_central
254                            .invite_all(&case, &id, [&bob_central, &charlie_central])
255                            .await
256                            .unwrap();
257
258                        assert!(alice_central.pending_proposals(&id).await.is_empty());
259                        let proposal = alice_central
260                            .context
261                            .new_remove_proposal(&id, charlie_central.get_client_id().await)
262                            .await
263                            .unwrap()
264                            .proposal;
265                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
266                        bob_central
267                            .context
268                            .decrypt_message(&id, proposal.to_bytes().unwrap())
269                            .await
270                            .unwrap();
271                        let commit = bob_central
272                            .context
273                            .commit_pending_proposals(&id)
274                            .await
275                            .unwrap()
276                            .unwrap()
277                            .commit;
278                        bob_central.context.commit_accepted(&id).await.unwrap();
279                        assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
280
281                        // if 'new_proposal' wasn't durable this would fail because proposal would
282                        // not be referenced in commit
283                        alice_central
284                            .context
285                            .decrypt_message(&id, commit.to_bytes().unwrap())
286                            .await
287                            .unwrap();
288                        assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
289                    })
290                },
291            )
292            .await
293        }
294    }
295
296    mod propose_self_update {
297        use super::*;
298
299        #[apply(all_cred_cipher)]
300        #[wasm_bindgen_test]
301        async fn can_propose_updating(case: TestCase) {
302            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
303                Box::pin(async move {
304                    let id = conversation_id();
305                    alice_central
306                        .context
307                        .new_conversation(&id, case.credential_type, case.cfg.clone())
308                        .await
309                        .unwrap();
310                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
311
312                    let bob_keys = bob_central
313                        .get_conversation_unchecked(&id)
314                        .await
315                        .signature_keys()
316                        .collect::<Vec<SignaturePublicKey>>();
317                    let alice_keys = alice_central
318                        .get_conversation_unchecked(&id)
319                        .await
320                        .signature_keys()
321                        .collect::<Vec<SignaturePublicKey>>();
322                    assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
323                    let alice_key = alice_central
324                        .encryption_key_of(&id, alice_central.get_client_id().await)
325                        .await;
326
327                    let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
328                    bob_central
329                        .context
330                        .decrypt_message(&id, proposal.to_bytes().unwrap())
331                        .await
332                        .unwrap();
333                    let commit = bob_central
334                        .context
335                        .commit_pending_proposals(&id)
336                        .await
337                        .unwrap()
338                        .unwrap()
339                        .commit;
340
341                    // before merging, commit is not applied
342                    assert!(bob_central
343                        .get_conversation_unchecked(&id)
344                        .await
345                        .encryption_keys()
346                        .contains(&alice_key));
347                    bob_central.context.commit_accepted(&id).await.unwrap();
348                    assert!(!bob_central
349                        .get_conversation_unchecked(&id)
350                        .await
351                        .encryption_keys()
352                        .contains(&alice_key));
353
354                    assert!(alice_central
355                        .get_conversation_unchecked(&id)
356                        .await
357                        .encryption_keys()
358                        .contains(&alice_key));
359                    // if 'new_proposal' wasn't durable this would fail because proposal would
360                    // not be referenced in commit
361                    alice_central
362                        .context
363                        .decrypt_message(&id, commit.to_bytes().unwrap())
364                        .await
365                        .unwrap();
366                    assert!(!alice_central
367                        .get_conversation_unchecked(&id)
368                        .await
369                        .encryption_keys()
370                        .contains(&alice_key));
371
372                    // ensuring both can encrypt messages
373                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
374                })
375            })
376            .await;
377        }
378    }
379
380    mod delivery_semantics {
381        use super::*;
382
383        #[apply(all_cred_cipher)]
384        #[wasm_bindgen_test]
385        async fn should_prevent_out_of_order_proposals(case: TestCase) {
386            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
387                Box::pin(async move {
388                    let id = conversation_id();
389                    alice_central
390                        .context
391                        .new_conversation(&id, case.credential_type, case.cfg.clone())
392                        .await
393                        .unwrap();
394                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
395
396                    let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
397
398                    bob_central
399                        .context
400                        .decrypt_message(&id, &proposal.to_bytes().unwrap())
401                        .await
402                        .unwrap();
403                    bob_central.context.commit_pending_proposals(&id).await.unwrap();
404                    // epoch++
405                    bob_central.context.commit_accepted(&id).await.unwrap();
406
407                    // fails when we try to decrypt a proposal for past epoch
408                    let past_proposal = bob_central
409                        .context
410                        .decrypt_message(&id, &proposal.to_bytes().unwrap())
411                        .await;
412                    assert!(matches!(past_proposal.unwrap_err(), CryptoError::StaleProposal));
413                })
414            })
415            .await;
416        }
417    }
418}