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
163    use crate::mls::conversation::ConversationWithMls as _;
164    use crate::test_utils::*;
165
166    use super::*;
167
168    mod propose_add_members {
169        use super::*;
170
171        #[apply(all_cred_cipher)]
172        async fn can_propose_adding_members_to_conversation(case: TestContext) {
173            let [alice, bob, charlie] = case.sessions().await;
174            Box::pin(async move {
175                let conversation = case.create_conversation([&alice, &bob]).await;
176                assert!(!conversation.has_pending_proposals().await);
177
178                let proposal_guard = conversation.invite_proposal(&charlie).await;
179                assert_eq!(proposal_guard.conversation().pending_proposal_count().await, 1);
180                let commit_guard = proposal_guard
181                    .notify_members()
182                    .await
183                    .acting_as(&bob)
184                    .await
185                    .commit_pending_proposals()
186                    .await;
187                assert_eq!(commit_guard.conversation().members_counted_by(&bob).await, 3);
188                assert_eq!(commit_guard.conversation().members_counted_by(&alice).await, 2);
189
190                // if 'new_proposal' wasn't durable this would fail because proposal would
191                // not be referenced in commit
192                let conversation = commit_guard.notify_members().await;
193                assert_eq!(conversation.member_count().await, 3);
194                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await)
195            })
196            .await
197        }
198    }
199
200    mod propose_remove_members {
201        use super::*;
202
203        #[apply(all_cred_cipher)]
204        async fn can_propose_removing_members_from_conversation(case: TestContext) {
205            let [alice, bob, charlie] = case.sessions().await;
206            Box::pin(async move {
207                let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
208
209                assert!(!conversation.has_pending_proposals().await);
210                let proposal_guard = conversation.remove_proposal(&charlie).await;
211                assert_eq!(proposal_guard.conversation().pending_proposal_count().await, 1);
212                let conversation = proposal_guard
213                    .notify_members()
214                    .await
215                    .acting_as(&bob)
216                    .await
217                    .commit_pending_proposals_notify()
218                    .await;
219                assert_eq!(conversation.member_count().await, 2);
220                assert!(conversation.is_functional_and_contains([&alice, &bob]).await)
221            })
222            .await
223        }
224    }
225
226    mod propose_self_update {
227        use super::*;
228
229        #[apply(all_cred_cipher)]
230        async fn can_propose_updating(case: TestContext) {
231            let [alice, bob] = case.sessions().await;
232            Box::pin(async move {
233                let conversation = case.create_conversation([&alice, &bob]).await;
234
235                let bob_keys = conversation
236                    .guard_of(&bob)
237                    .await
238                    .conversation()
239                    .await
240                    .signature_keys()
241                    .collect::<Vec<_>>();
242                let alice_keys = conversation
243                    .guard()
244                    .await
245                    .conversation()
246                    .await
247                    .signature_keys()
248                    .collect::<Vec<_>>();
249                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
250                let alice_key = conversation.encryption_public_key().await;
251
252                let commit_guard = conversation
253                    .update_proposal_notify()
254                    .await
255                    .acting_as(&bob)
256                    .await
257                    .commit_pending_proposals()
258                    .await;
259
260                let conversation = commit_guard.conversation();
261
262                assert!(
263                    !conversation
264                        .guard_of(&bob)
265                        .await
266                        .conversation()
267                        .await
268                        .encryption_keys()
269                        .contains(&alice_key)
270                );
271
272                assert!(
273                    conversation
274                        .guard_of(&alice)
275                        .await
276                        .conversation()
277                        .await
278                        .encryption_keys()
279                        .contains(&alice_key)
280                );
281                // if 'new_proposal' wasn't durable this would fail because proposal would
282                // not be referenced in commit
283                let conversation = commit_guard.notify_members().await;
284                assert!(
285                    !conversation
286                        .guard_of(&alice)
287                        .await
288                        .conversation()
289                        .await
290                        .encryption_keys()
291                        .contains(&alice_key)
292                );
293
294                // ensuring both can encrypt messages
295                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
296            })
297            .await;
298        }
299    }
300
301    mod delivery_semantics {
302        use super::*;
303
304        #[apply(all_cred_cipher)]
305        async fn should_prevent_out_of_order_proposals(case: TestContext) {
306            let [alice, bob] = case.sessions().await;
307            Box::pin(async move {
308                let conversation = case.create_conversation([&alice, &bob]).await;
309                let id = conversation.id().clone();
310
311                let proposal_guard = conversation.update_proposal().await;
312                let proposal = proposal_guard.message();
313                proposal_guard
314                    .notify_members()
315                    .await
316                    .acting_as(&bob)
317                    .await
318                    .commit_pending_proposals_notify()
319                    .await;
320                // epoch++
321
322                // fails when we try to decrypt a proposal for past epoch
323                let past_proposal = bob
324                    .transaction
325                    .conversation(&id)
326                    .await
327                    .unwrap()
328                    .decrypt_message(&proposal.to_bytes().unwrap())
329                    .await;
330                assert!(matches!(past_proposal.unwrap_err(), Error::StaleProposal));
331            })
332            .await;
333        }
334    }
335}