core_crypto/mls/conversation/
proposal.rs1use openmls::{binary_tree::LeafNodeIndex, framing::MlsMessageOut, key_packages::KeyPackageIn, prelude::LeafNode};
9
10use mls_crypto_provider::MlsCryptoProvider;
11
12use crate::{
13 e2e_identity::init_certificates::NewCrlDistributionPoint, mls::credential::crl::get_new_crl_distribution_points,
14};
15use crate::{
16 mls::credential::crl::extract_crl_uris_from_credentials,
17 prelude::{Client, MlsConversation, MlsProposalRef},
18 CryptoError, CryptoResult, MlsError,
19};
20
21impl MlsConversation {
23 #[cfg_attr(test, crate::durable)]
25 pub async fn propose_add_member(
26 &mut self,
27 client: &Client,
28 backend: &MlsCryptoProvider,
29 key_package: KeyPackageIn,
30 ) -> CryptoResult<MlsProposalBundle> {
31 let signer = &self
32 .find_current_credential_bundle(client)
33 .await
34 .map_err(|_| CryptoError::IdentityInitializationError)?
35 .signature_key;
36
37 let crl_new_distribution_points = get_new_crl_distribution_points(
38 backend,
39 extract_crl_uris_from_credentials(std::iter::once(key_package.credential().mls_credential()))?,
40 )
41 .await?;
42
43 let (proposal, proposal_ref) = self
44 .group
45 .propose_add_member(backend, signer, key_package)
46 .await
47 .map_err(MlsError::from)?;
48 let proposal = MlsProposalBundle {
49 proposal,
50 proposal_ref: proposal_ref.into(),
51 crl_new_distribution_points,
52 };
53 self.persist_group_when_changed(&backend.keystore(), false).await?;
54 Ok(proposal)
55 }
56
57 #[cfg_attr(test, crate::durable)]
59 pub async fn propose_remove_member(
60 &mut self,
61 client: &Client,
62 backend: &MlsCryptoProvider,
63 member: LeafNodeIndex,
64 ) -> CryptoResult<MlsProposalBundle> {
65 let signer = &self
66 .find_current_credential_bundle(client)
67 .await
68 .map_err(|_| CryptoError::IdentityInitializationError)?
69 .signature_key;
70 let proposal = self
71 .group
72 .propose_remove_member(backend, signer, member)
73 .map_err(MlsError::from)
74 .map_err(CryptoError::from)
75 .map(MlsProposalBundle::from)?;
76 self.persist_group_when_changed(&backend.keystore(), false).await?;
77 Ok(proposal)
78 }
79
80 #[cfg_attr(test, crate::durable)]
82 pub async fn propose_self_update(
83 &mut self,
84 client: &Client,
85 backend: &MlsCryptoProvider,
86 ) -> CryptoResult<MlsProposalBundle> {
87 self.propose_explicit_self_update(client, backend, None).await
88 }
89
90 #[cfg_attr(test, crate::durable)]
92 pub async fn propose_explicit_self_update(
93 &mut self,
94 client: &Client,
95 backend: &MlsCryptoProvider,
96 leaf_node: Option<LeafNode>,
97 ) -> CryptoResult<MlsProposalBundle> {
98 let msg_signer = &self
99 .find_current_credential_bundle(client)
100 .await
101 .map_err(|_| CryptoError::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_err(MlsError::from)
114 .map(MlsProposalBundle::from)?;
115
116 self.persist_group_when_changed(&backend.keystore(), false).await?;
117 Ok(proposal)
118 }
119}
120
121#[derive(Debug)]
123pub struct MlsProposalBundle {
124 pub proposal: MlsMessageOut,
126 pub proposal_ref: MlsProposalRef,
128 pub crl_new_distribution_points: NewCrlDistributionPoint,
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 #[allow(clippy::type_complexity)]
147 pub fn to_bytes(self) -> CryptoResult<(Vec<u8>, Vec<u8>, NewCrlDistributionPoint)> {
148 use openmls::prelude::TlsSerializeTrait as _;
149 let proposal = self.proposal.tls_serialize_detached().map_err(MlsError::from)?;
150 let proposal_ref = self.proposal_ref.to_bytes();
151
152 Ok((proposal, proposal_ref, self.crl_new_distribution_points))
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use itertools::Itertools;
159 use openmls::prelude::SignaturePublicKey;
160 use wasm_bindgen_test::*;
161
162 use crate::{prelude::MlsCommitBundle, test_utils::*};
163
164 use super::*;
165
166 wasm_bindgen_test_configure!(run_in_browser);
167
168 mod propose_add_members {
169 use super::*;
170
171 #[apply(all_cred_cipher)]
172 #[wasm_bindgen_test]
173 async fn can_propose_adding_members_to_conversation(case: TestCase) {
174 run_test_with_client_ids(
175 case.clone(),
176 ["alice", "bob", "charlie"],
177 move |[mut alice_central, bob_central, mut charlie_central]| {
178 Box::pin(async move {
179 let id = conversation_id();
180 alice_central
181 .context
182 .new_conversation(&id, case.credential_type, case.cfg.clone())
183 .await
184 .unwrap();
185 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
186 let charlie_kp = charlie_central.get_one_key_package(&case).await;
187
188 assert!(alice_central.pending_proposals(&id).await.is_empty());
189 let proposal = alice_central
190 .context
191 .new_add_proposal(&id, charlie_kp)
192 .await
193 .unwrap()
194 .proposal;
195 assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
196 bob_central
197 .context
198 .decrypt_message(&id, proposal.to_bytes().unwrap())
199 .await
200 .unwrap();
201 let MlsCommitBundle { commit, welcome, .. } = bob_central
202 .context
203 .commit_pending_proposals(&id)
204 .await
205 .unwrap()
206 .unwrap();
207 bob_central.context.commit_accepted(&id).await.unwrap();
208 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
209
210 alice_central
213 .context
214 .decrypt_message(&id, commit.to_bytes().unwrap())
215 .await
216 .unwrap();
217 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
218
219 charlie_central
220 .try_join_from_welcome(
221 &id,
222 welcome.unwrap().into(),
223 case.custom_cfg(),
224 vec![&alice_central, &bob_central],
225 )
226 .await
227 .unwrap();
228 assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
229 })
230 },
231 )
232 .await
233 }
234 }
235
236 mod propose_remove_members {
237 use super::*;
238
239 #[apply(all_cred_cipher)]
240 #[wasm_bindgen_test]
241 async fn can_propose_removing_members_from_conversation(case: TestCase) {
242 run_test_with_client_ids(
243 case.clone(),
244 ["alice", "bob", "charlie"],
245 move |[mut alice_central, bob_central, charlie_central]| {
246 Box::pin(async move {
247 let id = conversation_id();
248 alice_central
249 .context
250 .new_conversation(&id, case.credential_type, case.cfg.clone())
251 .await
252 .unwrap();
253 alice_central
254 .invite_all(&case, &id, [&bob_central, &charlie_central])
255 .await
256 .unwrap();
257
258 assert!(alice_central.pending_proposals(&id).await.is_empty());
259 let proposal = alice_central
260 .context
261 .new_remove_proposal(&id, charlie_central.get_client_id().await)
262 .await
263 .unwrap()
264 .proposal;
265 assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
266 bob_central
267 .context
268 .decrypt_message(&id, proposal.to_bytes().unwrap())
269 .await
270 .unwrap();
271 let commit = bob_central
272 .context
273 .commit_pending_proposals(&id)
274 .await
275 .unwrap()
276 .unwrap()
277 .commit;
278 bob_central.context.commit_accepted(&id).await.unwrap();
279 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
280
281 alice_central
284 .context
285 .decrypt_message(&id, commit.to_bytes().unwrap())
286 .await
287 .unwrap();
288 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
289 })
290 },
291 )
292 .await
293 }
294 }
295
296 mod propose_self_update {
297 use super::*;
298
299 #[apply(all_cred_cipher)]
300 #[wasm_bindgen_test]
301 async fn can_propose_updating(case: TestCase) {
302 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
303 Box::pin(async move {
304 let id = conversation_id();
305 alice_central
306 .context
307 .new_conversation(&id, case.credential_type, case.cfg.clone())
308 .await
309 .unwrap();
310 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
311
312 let bob_keys = bob_central
313 .get_conversation_unchecked(&id)
314 .await
315 .signature_keys()
316 .collect::<Vec<SignaturePublicKey>>();
317 let alice_keys = alice_central
318 .get_conversation_unchecked(&id)
319 .await
320 .signature_keys()
321 .collect::<Vec<SignaturePublicKey>>();
322 assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
323 let alice_key = alice_central
324 .encryption_key_of(&id, alice_central.get_client_id().await)
325 .await;
326
327 let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
328 bob_central
329 .context
330 .decrypt_message(&id, proposal.to_bytes().unwrap())
331 .await
332 .unwrap();
333 let commit = bob_central
334 .context
335 .commit_pending_proposals(&id)
336 .await
337 .unwrap()
338 .unwrap()
339 .commit;
340
341 assert!(bob_central
343 .get_conversation_unchecked(&id)
344 .await
345 .encryption_keys()
346 .contains(&alice_key));
347 bob_central.context.commit_accepted(&id).await.unwrap();
348 assert!(!bob_central
349 .get_conversation_unchecked(&id)
350 .await
351 .encryption_keys()
352 .contains(&alice_key));
353
354 assert!(alice_central
355 .get_conversation_unchecked(&id)
356 .await
357 .encryption_keys()
358 .contains(&alice_key));
359 alice_central
362 .context
363 .decrypt_message(&id, commit.to_bytes().unwrap())
364 .await
365 .unwrap();
366 assert!(!alice_central
367 .get_conversation_unchecked(&id)
368 .await
369 .encryption_keys()
370 .contains(&alice_key));
371
372 assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
374 })
375 })
376 .await;
377 }
378 }
379
380 mod delivery_semantics {
381 use super::*;
382
383 #[apply(all_cred_cipher)]
384 #[wasm_bindgen_test]
385 async fn should_prevent_out_of_order_proposals(case: TestCase) {
386 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
387 Box::pin(async move {
388 let id = conversation_id();
389 alice_central
390 .context
391 .new_conversation(&id, case.credential_type, case.cfg.clone())
392 .await
393 .unwrap();
394 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
395
396 let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
397
398 bob_central
399 .context
400 .decrypt_message(&id, &proposal.to_bytes().unwrap())
401 .await
402 .unwrap();
403 bob_central.context.commit_pending_proposals(&id).await.unwrap();
404 bob_central.context.commit_accepted(&id).await.unwrap();
406
407 let past_proposal = bob_central
409 .context
410 .decrypt_message(&id, &proposal.to_bytes().unwrap())
411 .await;
412 assert!(matches!(past_proposal.unwrap_err(), CryptoError::StaleProposal));
413 })
414 })
415 .await;
416 }
417 }
418}