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::NewCrlDistributionPoints,
16    mls::credential::crl::{extract_crl_uris_from_credentials, get_new_crl_distribution_points},
17    prelude::{MlsConversation, MlsProposalRef, Session},
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: &Session,
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: &Session,
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: &Session,
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: &Session,
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.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(MlsProposalBundle::from)
114        .map_err(MlsError::wrap("proposing self update"))?;
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: NewCrlDistributionPoints,
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) -> Result<(Vec<u8>, Vec<u8>, NewCrlDistributionPoints)> {
148        use openmls::prelude::TlsSerializeTrait as _;
149        let proposal = self
150            .proposal
151            .tls_serialize_detached()
152            .map_err(Error::tls_serialize("proposal"))?;
153        let proposal_ref = self.proposal_ref.to_bytes();
154
155        Ok((proposal, proposal_ref, self.crl_new_distribution_points))
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use itertools::Itertools;
162    use openmls::prelude::SignaturePublicKey;
163    use wasm_bindgen_test::*;
164
165    use crate::test_utils::*;
166
167    use super::*;
168
169    wasm_bindgen_test_configure!(run_in_browser);
170
171    mod propose_add_members {
172        use super::*;
173
174        #[apply(all_cred_cipher)]
175        #[wasm_bindgen_test]
176        async fn can_propose_adding_members_to_conversation(case: TestContext) {
177            let [mut alice_central, bob_central, mut charlie_central] = case.sessions().await;
178            Box::pin(async move {
179                let id = conversation_id();
180                alice_central
181                    .transaction
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                    .transaction
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                    .transaction
198                    .conversation(&id)
199                    .await
200                    .unwrap()
201                    .decrypt_message(proposal.to_bytes().unwrap())
202                    .await
203                    .unwrap();
204                bob_central
205                    .transaction
206                    .conversation(&id)
207                    .await
208                    .unwrap()
209                    .commit_pending_proposals()
210                    .await
211                    .unwrap();
212                let commit = bob_central.mls_transport().await.latest_commit().await;
213                let welcome = bob_central.mls_transport().await.latest_welcome_message().await;
214                assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
215
216                // if 'new_proposal' wasn't durable this would fail because proposal would
217                // not be referenced in commit
218                alice_central
219                    .transaction
220                    .conversation(&id)
221                    .await
222                    .unwrap()
223                    .decrypt_message(commit.to_bytes().unwrap())
224                    .await
225                    .unwrap();
226                assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
227
228                charlie_central
229                    .try_join_from_welcome(
230                        &id,
231                        welcome.into(),
232                        case.custom_cfg(),
233                        vec![&alice_central, &bob_central],
234                    )
235                    .await
236                    .unwrap();
237                assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
238            })
239            .await
240        }
241    }
242
243    mod propose_remove_members {
244        use super::*;
245
246        #[apply(all_cred_cipher)]
247        #[wasm_bindgen_test]
248        async fn can_propose_removing_members_from_conversation(case: TestContext) {
249            let [mut alice_central, bob_central, charlie_central] = case.sessions().await;
250            Box::pin(async move {
251                let id = conversation_id();
252                alice_central
253                    .transaction
254                    .new_conversation(&id, case.credential_type, case.cfg.clone())
255                    .await
256                    .unwrap();
257                alice_central
258                    .invite_all(&case, &id, [&bob_central, &charlie_central])
259                    .await
260                    .unwrap();
261
262                assert!(alice_central.pending_proposals(&id).await.is_empty());
263                let proposal = alice_central
264                    .transaction
265                    .new_remove_proposal(&id, charlie_central.get_client_id().await)
266                    .await
267                    .unwrap()
268                    .proposal;
269                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
270                bob_central
271                    .transaction
272                    .conversation(&id)
273                    .await
274                    .unwrap()
275                    .decrypt_message(proposal.to_bytes().unwrap())
276                    .await
277                    .unwrap();
278                bob_central
279                    .transaction
280                    .conversation(&id)
281                    .await
282                    .unwrap()
283                    .commit_pending_proposals()
284                    .await
285                    .unwrap();
286                let commit = bob_central.mls_transport().await.latest_commit().await;
287                assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
288
289                // if 'new_proposal' wasn't durable this would fail because proposal would
290                // not be referenced in commit
291                alice_central
292                    .transaction
293                    .conversation(&id)
294                    .await
295                    .unwrap()
296                    .decrypt_message(commit.to_bytes().unwrap())
297                    .await
298                    .unwrap();
299                assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
300            })
301            .await
302        }
303    }
304
305    mod propose_self_update {
306        use super::*;
307
308        #[apply(all_cred_cipher)]
309        #[wasm_bindgen_test]
310        async fn can_propose_updating(case: TestContext) {
311            let [alice_central, bob_central] = case.sessions().await;
312            Box::pin(async move {
313                let id = conversation_id();
314                alice_central
315                    .transaction
316                    .new_conversation(&id, case.credential_type, case.cfg.clone())
317                    .await
318                    .unwrap();
319                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
320
321                let bob_keys = bob_central
322                    .get_conversation_unchecked(&id)
323                    .await
324                    .signature_keys()
325                    .collect::<Vec<SignaturePublicKey>>();
326                let alice_keys = alice_central
327                    .get_conversation_unchecked(&id)
328                    .await
329                    .signature_keys()
330                    .collect::<Vec<SignaturePublicKey>>();
331                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
332                let alice_key = alice_central
333                    .encryption_key_of(&id, alice_central.get_client_id().await)
334                    .await;
335
336                let proposal = alice_central
337                    .transaction
338                    .new_update_proposal(&id)
339                    .await
340                    .unwrap()
341                    .proposal;
342                bob_central
343                    .transaction
344                    .conversation(&id)
345                    .await
346                    .unwrap()
347                    .decrypt_message(proposal.to_bytes().unwrap())
348                    .await
349                    .unwrap();
350                bob_central
351                    .transaction
352                    .conversation(&id)
353                    .await
354                    .unwrap()
355                    .commit_pending_proposals()
356                    .await
357                    .unwrap();
358                let commit = bob_central.mls_transport().await.latest_commit().await;
359
360                assert!(
361                    !bob_central
362                        .get_conversation_unchecked(&id)
363                        .await
364                        .encryption_keys()
365                        .contains(&alice_key)
366                );
367
368                assert!(
369                    alice_central
370                        .get_conversation_unchecked(&id)
371                        .await
372                        .encryption_keys()
373                        .contains(&alice_key)
374                );
375                // if 'new_proposal' wasn't durable this would fail because proposal would
376                // not be referenced in commit
377                alice_central
378                    .transaction
379                    .conversation(&id)
380                    .await
381                    .unwrap()
382                    .decrypt_message(commit.to_bytes().unwrap())
383                    .await
384                    .unwrap();
385                assert!(
386                    !alice_central
387                        .get_conversation_unchecked(&id)
388                        .await
389                        .encryption_keys()
390                        .contains(&alice_key)
391                );
392
393                // ensuring both can encrypt messages
394                assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
395            })
396            .await;
397        }
398    }
399
400    mod delivery_semantics {
401        use super::*;
402
403        #[apply(all_cred_cipher)]
404        #[wasm_bindgen_test]
405        async fn should_prevent_out_of_order_proposals(case: TestContext) {
406            let [alice_central, bob_central] = case.sessions().await;
407            Box::pin(async move {
408                let id = conversation_id();
409                alice_central
410                    .transaction
411                    .new_conversation(&id, case.credential_type, case.cfg.clone())
412                    .await
413                    .unwrap();
414                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
415
416                let proposal = alice_central
417                    .transaction
418                    .new_update_proposal(&id)
419                    .await
420                    .unwrap()
421                    .proposal;
422
423                bob_central
424                    .transaction
425                    .conversation(&id)
426                    .await
427                    .unwrap()
428                    .decrypt_message(&proposal.to_bytes().unwrap())
429                    .await
430                    .unwrap();
431                bob_central
432                    .transaction
433                    .conversation(&id)
434                    .await
435                    .unwrap()
436                    .commit_pending_proposals()
437                    .await
438                    .unwrap();
439                // epoch++
440
441                // fails when we try to decrypt a proposal for past epoch
442                let past_proposal = bob_central
443                    .transaction
444                    .conversation(&id)
445                    .await
446                    .unwrap()
447                    .decrypt_message(&proposal.to_bytes().unwrap())
448                    .await;
449                assert!(matches!(past_proposal.unwrap_err(), Error::StaleProposal));
450            })
451            .await;
452        }
453    }
454}