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 core_crypto_keystore::Database;
9use openmls::{binary_tree::LeafNodeIndex, framing::MlsMessageOut, key_packages::KeyPackageIn, prelude::LeafNode};
10
11use super::{Error, Result};
12use crate::{MlsConversation, MlsError, MlsProposalRef, Session, mls_provider::MlsCryptoProvider};
13
14/// Creating proposals
15impl MlsConversation {
16    /// see [openmls::group::MlsGroup::propose_add_member]
17    #[cfg_attr(test, crate::durable)]
18    pub async fn propose_add_member(
19        &mut self,
20        client: &Session<Database>,
21        backend: &MlsCryptoProvider,
22        database: &Database,
23        key_package: KeyPackageIn,
24    ) -> Result<MlsProposalBundle> {
25        let signer = &self
26            .find_current_credential(client)
27            .await
28            .map_err(|_| Error::IdentityInitializationError)?
29            .signature_key_pair;
30
31        let (proposal, proposal_ref) = self
32            .group
33            .propose_add_member(backend, signer, key_package)
34            .await
35            .map_err(MlsError::wrap("propose add member"))?;
36        let proposal = MlsProposalBundle {
37            proposal,
38            proposal_ref: proposal_ref.into(),
39        };
40        self.persist_group_when_changed(database, false).await?;
41        Ok(proposal)
42    }
43
44    /// see [openmls::group::MlsGroup::propose_remove_member]
45    #[cfg_attr(test, crate::durable)]
46    pub async fn propose_remove_member(
47        &mut self,
48        client: &Session<Database>,
49        provider: &MlsCryptoProvider,
50        database: &Database,
51        member: LeafNodeIndex,
52    ) -> Result<MlsProposalBundle> {
53        let signer = &self
54            .find_current_credential(client)
55            .await
56            .map_err(|_| Error::IdentityInitializationError)?
57            .signature_key_pair;
58        let proposal = self
59            .group
60            .propose_remove_member(provider, signer, member)
61            .map_err(MlsError::wrap("propose remove member"))
62            .map(MlsProposalBundle::from)?;
63        self.persist_group_when_changed(database, false).await?;
64        Ok(proposal)
65    }
66
67    /// see [openmls::group::MlsGroup::propose_self_update]
68    #[cfg_attr(test, crate::durable)]
69    pub async fn propose_self_update(
70        &mut self,
71        client: &Session<Database>,
72        provider: &MlsCryptoProvider,
73        database: &Database,
74    ) -> Result<MlsProposalBundle> {
75        self.propose_explicit_self_update(client, provider, database, None)
76            .await
77    }
78
79    /// see [openmls::group::MlsGroup::propose_self_update]
80    #[cfg_attr(test, crate::durable)]
81    pub async fn propose_explicit_self_update(
82        &mut self,
83        client: &Session<Database>,
84        backend: &MlsCryptoProvider,
85        database: &Database,
86        leaf_node: Option<LeafNode>,
87    ) -> Result<MlsProposalBundle> {
88        let msg_signer = &self
89            .find_current_credential(client)
90            .await
91            .map_err(|_| Error::IdentityInitializationError)?
92            .signature_key_pair;
93
94        let proposal = if let Some(own_leaf) = leaf_node {
95            let credential = self.find_credential_for_leaf_node(client, &own_leaf).await?;
96            self.group
97                .propose_explicit_self_update(backend, msg_signer, own_leaf, credential.signature_key())
98                .await
99        } else {
100            self.group.propose_self_update(backend, msg_signer).await
101        }
102        .map(MlsProposalBundle::from)
103        .map_err(MlsError::wrap("proposing explicit self update"))?;
104
105        self.persist_group_when_changed(database, false).await?;
106        Ok(proposal)
107    }
108}
109
110/// Returned when a Proposal is created. Helps roll backing a local proposal
111#[derive(Debug)]
112pub struct MlsProposalBundle {
113    /// The proposal message
114    pub proposal: MlsMessageOut,
115    /// A unique identifier of the proposal to rollback it later if required
116    pub proposal_ref: MlsProposalRef,
117}
118
119impl From<(MlsMessageOut, openmls::prelude::hash_ref::ProposalRef)> for MlsProposalBundle {
120    fn from((proposal, proposal_ref): (MlsMessageOut, openmls::prelude::hash_ref::ProposalRef)) -> Self {
121        Self {
122            proposal,
123            proposal_ref: proposal_ref.into(),
124        }
125    }
126}
127
128impl MlsProposalBundle {
129    /// Serializes both wrapped objects into TLS and return them as a tuple of byte arrays.
130    /// 0 -> proposal
131    /// 1 -> proposal reference
132    #[allow(clippy::type_complexity)]
133    pub fn to_bytes(self) -> Result<(Vec<u8>, Vec<u8>)> {
134        use openmls::prelude::TlsSerializeTrait as _;
135        let proposal = self
136            .proposal
137            .tls_serialize_detached()
138            .map_err(Error::tls_serialize("proposal"))?;
139        let proposal_ref = self.proposal_ref.to_bytes();
140
141        Ok((proposal, proposal_ref))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use itertools::Itertools;
148
149    use super::*;
150    use crate::{mls::conversation::ConversationWithMls as _, test_utils::*};
151
152    mod propose_add_members {
153        use super::*;
154
155        #[apply(all_cred_cipher)]
156        async fn can_propose_adding_members_to_conversation(case: TestContext) {
157            let [alice, bob, charlie] = case.sessions().await;
158            Box::pin(async move {
159                let conversation = case.create_conversation([&alice, &bob]).await;
160                assert!(!conversation.has_pending_proposals().await);
161
162                let proposal_guard = conversation.invite_proposal(&charlie).await;
163                assert_eq!(proposal_guard.conversation().pending_proposal_count().await, 1);
164                let commit_guard = proposal_guard
165                    .notify_members()
166                    .await
167                    .acting_as(&bob)
168                    .await
169                    .commit_pending_proposals()
170                    .await;
171                assert_eq!(commit_guard.conversation().members_counted_by(&bob).await, 3);
172                assert_eq!(commit_guard.conversation().members_counted_by(&alice).await, 2);
173
174                // if 'new_proposal' wasn't durable this would fail because proposal would
175                // not be referenced in commit
176                let conversation = commit_guard.notify_members().await;
177                assert_eq!(conversation.member_count().await, 3);
178                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await)
179            })
180            .await
181        }
182    }
183
184    mod propose_remove_members {
185        use super::*;
186
187        #[apply(all_cred_cipher)]
188        async fn can_propose_removing_members_from_conversation(case: TestContext) {
189            let [alice, bob, charlie] = case.sessions().await;
190            Box::pin(async move {
191                let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
192
193                assert!(!conversation.has_pending_proposals().await);
194                let proposal_guard = conversation.remove_proposal(&charlie).await;
195                assert_eq!(proposal_guard.conversation().pending_proposal_count().await, 1);
196                let conversation = proposal_guard
197                    .notify_members()
198                    .await
199                    .acting_as(&bob)
200                    .await
201                    .commit_pending_proposals_notify()
202                    .await;
203                assert_eq!(conversation.member_count().await, 2);
204                assert!(conversation.is_functional_and_contains([&alice, &bob]).await)
205            })
206            .await
207        }
208    }
209
210    mod propose_self_update {
211        use super::*;
212
213        #[apply(all_cred_cipher)]
214        async fn can_propose_updating(case: TestContext) {
215            let [alice, bob] = case.sessions().await;
216            Box::pin(async move {
217                let conversation = case.create_conversation([&alice, &bob]).await;
218
219                let bob_keys = conversation
220                    .guard_of(&bob)
221                    .await
222                    .conversation()
223                    .await
224                    .signature_keys()
225                    .collect::<Vec<_>>();
226                let alice_keys = conversation
227                    .guard()
228                    .await
229                    .conversation()
230                    .await
231                    .signature_keys()
232                    .collect::<Vec<_>>();
233                assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
234                let alice_key = conversation.encryption_public_key().await;
235
236                let commit_guard = conversation
237                    .update_proposal_notify()
238                    .await
239                    .acting_as(&bob)
240                    .await
241                    .commit_pending_proposals()
242                    .await;
243
244                let conversation = commit_guard.conversation();
245
246                assert!(
247                    !conversation
248                        .guard_of(&bob)
249                        .await
250                        .conversation()
251                        .await
252                        .encryption_keys()
253                        .contains(&alice_key)
254                );
255
256                assert!(
257                    conversation
258                        .guard_of(&alice)
259                        .await
260                        .conversation()
261                        .await
262                        .encryption_keys()
263                        .contains(&alice_key)
264                );
265                // if 'new_proposal' wasn't durable this would fail because proposal would
266                // not be referenced in commit
267                let conversation = commit_guard.notify_members().await;
268                assert!(
269                    !conversation
270                        .guard_of(&alice)
271                        .await
272                        .conversation()
273                        .await
274                        .encryption_keys()
275                        .contains(&alice_key)
276                );
277
278                // ensuring both can encrypt messages
279                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
280            })
281            .await;
282        }
283    }
284
285    mod delivery_semantics {
286        use super::*;
287
288        #[apply(all_cred_cipher)]
289        async fn should_prevent_out_of_order_proposals(case: TestContext) {
290            let [alice, bob] = case.sessions().await;
291            Box::pin(async move {
292                let conversation = case.create_conversation([&alice, &bob]).await;
293                let id = conversation.id().clone();
294
295                let proposal_guard = conversation.update_proposal().await;
296                let proposal = proposal_guard.message();
297                proposal_guard
298                    .notify_members()
299                    .await
300                    .acting_as(&bob)
301                    .await
302                    .commit_pending_proposals_notify()
303                    .await;
304                // epoch++
305
306                // fails when we try to decrypt a proposal for past epoch
307                let past_proposal = bob
308                    .transaction
309                    .conversation(&id)
310                    .await
311                    .unwrap()
312                    .decrypt_message(&proposal.to_bytes().unwrap())
313                    .await;
314                assert!(matches!(past_proposal.unwrap_err(), Error::StaleProposal));
315            })
316            .await;
317        }
318    }
319}