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