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