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: TestCase) {
177            run_test_with_client_ids(
178                case.clone(),
179                ["alice", "bob", "charlie"],
180                move |[mut alice_central, bob_central, mut charlie_central]| {
181                    Box::pin(async move {
182                        let id = conversation_id();
183                        alice_central
184                            .context
185                            .new_conversation(&id, case.credential_type, case.cfg.clone())
186                            .await
187                            .unwrap();
188                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
189                        let charlie_kp = charlie_central.get_one_key_package(&case).await;
190
191                        assert!(alice_central.pending_proposals(&id).await.is_empty());
192                        let proposal = alice_central
193                            .context
194                            .new_add_proposal(&id, charlie_kp)
195                            .await
196                            .unwrap()
197                            .proposal;
198                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
199                        bob_central
200                            .context
201                            .conversation(&id)
202                            .await
203                            .unwrap()
204                            .decrypt_message(proposal.to_bytes().unwrap())
205                            .await
206                            .unwrap();
207                        bob_central
208                            .context
209                            .conversation(&id)
210                            .await
211                            .unwrap()
212                            .commit_pending_proposals()
213                            .await
214                            .unwrap();
215                        let commit = bob_central.mls_transport.latest_commit().await;
216                        let welcome = bob_central.mls_transport.latest_welcome_message().await;
217                        assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
218
219                        // if 'new_proposal' wasn't durable this would fail because proposal would
220                        // not be referenced in commit
221                        alice_central
222                            .context
223                            .conversation(&id)
224                            .await
225                            .unwrap()
226                            .decrypt_message(commit.to_bytes().unwrap())
227                            .await
228                            .unwrap();
229                        assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
230
231                        charlie_central
232                            .try_join_from_welcome(
233                                &id,
234                                welcome.into(),
235                                case.custom_cfg(),
236                                vec![&alice_central, &bob_central],
237                            )
238                            .await
239                            .unwrap();
240                        assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
241                    })
242                },
243            )
244            .await
245        }
246    }
247
248    mod propose_remove_members {
249        use super::*;
250
251        #[apply(all_cred_cipher)]
252        #[wasm_bindgen_test]
253        async fn can_propose_removing_members_from_conversation(case: TestCase) {
254            run_test_with_client_ids(
255                case.clone(),
256                ["alice", "bob", "charlie"],
257                move |[mut alice_central, bob_central, charlie_central]| {
258                    Box::pin(async move {
259                        let id = conversation_id();
260                        alice_central
261                            .context
262                            .new_conversation(&id, case.credential_type, case.cfg.clone())
263                            .await
264                            .unwrap();
265                        alice_central
266                            .invite_all(&case, &id, [&bob_central, &charlie_central])
267                            .await
268                            .unwrap();
269
270                        assert!(alice_central.pending_proposals(&id).await.is_empty());
271                        let proposal = alice_central
272                            .context
273                            .new_remove_proposal(&id, charlie_central.get_client_id().await)
274                            .await
275                            .unwrap()
276                            .proposal;
277                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
278                        bob_central
279                            .context
280                            .conversation(&id)
281                            .await
282                            .unwrap()
283                            .decrypt_message(proposal.to_bytes().unwrap())
284                            .await
285                            .unwrap();
286                        bob_central
287                            .context
288                            .conversation(&id)
289                            .await
290                            .unwrap()
291                            .commit_pending_proposals()
292                            .await
293                            .unwrap();
294                        let commit = bob_central.mls_transport.latest_commit().await;
295                        assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
296
297                        // if 'new_proposal' wasn't durable this would fail because proposal would
298                        // not be referenced in commit
299                        alice_central
300                            .context
301                            .conversation(&id)
302                            .await
303                            .unwrap()
304                            .decrypt_message(commit.to_bytes().unwrap())
305                            .await
306                            .unwrap();
307                        assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
308                    })
309                },
310            )
311            .await
312        }
313    }
314
315    mod propose_self_update {
316        use super::*;
317
318        #[apply(all_cred_cipher)]
319        #[wasm_bindgen_test]
320        async fn can_propose_updating(case: TestCase) {
321            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
322                Box::pin(async move {
323                    let id = conversation_id();
324                    alice_central
325                        .context
326                        .new_conversation(&id, case.credential_type, case.cfg.clone())
327                        .await
328                        .unwrap();
329                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
330
331                    let bob_keys = bob_central
332                        .get_conversation_unchecked(&id)
333                        .await
334                        .signature_keys()
335                        .collect::<Vec<SignaturePublicKey>>();
336                    let alice_keys = alice_central
337                        .get_conversation_unchecked(&id)
338                        .await
339                        .signature_keys()
340                        .collect::<Vec<SignaturePublicKey>>();
341                    assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
342                    let alice_key = alice_central
343                        .encryption_key_of(&id, alice_central.get_client_id().await)
344                        .await;
345
346                    let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
347                    bob_central
348                        .context
349                        .conversation(&id)
350                        .await
351                        .unwrap()
352                        .decrypt_message(proposal.to_bytes().unwrap())
353                        .await
354                        .unwrap();
355                    bob_central
356                        .context
357                        .conversation(&id)
358                        .await
359                        .unwrap()
360                        .commit_pending_proposals()
361                        .await
362                        .unwrap();
363                    let commit = bob_central.mls_transport.latest_commit().await;
364
365                    assert!(
366                        !bob_central
367                            .get_conversation_unchecked(&id)
368                            .await
369                            .encryption_keys()
370                            .contains(&alice_key)
371                    );
372
373                    assert!(
374                        alice_central
375                            .get_conversation_unchecked(&id)
376                            .await
377                            .encryption_keys()
378                            .contains(&alice_key)
379                    );
380                    // if 'new_proposal' wasn't durable this would fail because proposal would
381                    // not be referenced in commit
382                    alice_central
383                        .context
384                        .conversation(&id)
385                        .await
386                        .unwrap()
387                        .decrypt_message(commit.to_bytes().unwrap())
388                        .await
389                        .unwrap();
390                    assert!(
391                        !alice_central
392                            .get_conversation_unchecked(&id)
393                            .await
394                            .encryption_keys()
395                            .contains(&alice_key)
396                    );
397
398                    // ensuring both can encrypt messages
399                    assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
400                })
401            })
402            .await;
403        }
404    }
405
406    mod delivery_semantics {
407        use super::*;
408
409        #[apply(all_cred_cipher)]
410        #[wasm_bindgen_test]
411        async fn should_prevent_out_of_order_proposals(case: TestCase) {
412            run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
413                Box::pin(async move {
414                    let id = conversation_id();
415                    alice_central
416                        .context
417                        .new_conversation(&id, case.credential_type, case.cfg.clone())
418                        .await
419                        .unwrap();
420                    alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
421
422                    let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
423
424                    bob_central
425                        .context
426                        .conversation(&id)
427                        .await
428                        .unwrap()
429                        .decrypt_message(&proposal.to_bytes().unwrap())
430                        .await
431                        .unwrap();
432                    bob_central
433                        .context
434                        .conversation(&id)
435                        .await
436                        .unwrap()
437                        .commit_pending_proposals()
438                        .await
439                        .unwrap();
440                    // epoch++
441
442                    // fails when we try to decrypt a proposal for past epoch
443                    let past_proposal = bob_central
444                        .context
445                        .conversation(&id)
446                        .await
447                        .unwrap()
448                        .decrypt_message(&proposal.to_bytes().unwrap())
449                        .await;
450                    assert!(matches!(past_proposal.unwrap_err(), Error::StaleProposal));
451                })
452            })
453            .await;
454        }
455    }
456}