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