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