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 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
20impl MlsConversation {
22 #[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 #[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 #[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 #[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#[derive(Debug)]
123pub struct MlsProposalBundle {
124 pub proposal: MlsMessageOut,
126 pub proposal_ref: MlsProposalRef,
128 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 #[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 use wasm_bindgen_test::*;
164
165 use crate::test_utils::*;
166
167 use super::*;
168
169 wasm_bindgen_test_configure!(run_in_browser);
170
171 mod propose_add_members {
172 use super::*;
173
174 #[apply(all_cred_cipher)]
175 #[wasm_bindgen_test]
176 async fn can_propose_adding_members_to_conversation(case: TestCase) {
177 run_test_with_client_ids(
178 case.clone(),
179 ["alice", "bob", "charlie"],
180 move |[mut alice_central, bob_central, mut charlie_central]| {
181 Box::pin(async move {
182 let id = conversation_id();
183 alice_central
184 .context
185 .new_conversation(&id, case.credential_type, case.cfg.clone())
186 .await
187 .unwrap();
188 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
189 let charlie_kp = charlie_central.get_one_key_package(&case).await;
190
191 assert!(alice_central.pending_proposals(&id).await.is_empty());
192 let proposal = alice_central
193 .context
194 .new_add_proposal(&id, charlie_kp)
195 .await
196 .unwrap()
197 .proposal;
198 assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
199 bob_central
200 .context
201 .conversation(&id)
202 .await
203 .unwrap()
204 .decrypt_message(proposal.to_bytes().unwrap())
205 .await
206 .unwrap();
207 bob_central
208 .context
209 .conversation(&id)
210 .await
211 .unwrap()
212 .commit_pending_proposals()
213 .await
214 .unwrap();
215 let commit = bob_central.mls_transport.latest_commit().await;
216 let welcome = bob_central.mls_transport.latest_welcome_message().await;
217 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
218
219 alice_central
222 .context
223 .conversation(&id)
224 .await
225 .unwrap()
226 .decrypt_message(commit.to_bytes().unwrap())
227 .await
228 .unwrap();
229 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
230
231 charlie_central
232 .try_join_from_welcome(
233 &id,
234 welcome.into(),
235 case.custom_cfg(),
236 vec![&alice_central, &bob_central],
237 )
238 .await
239 .unwrap();
240 assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
241 })
242 },
243 )
244 .await
245 }
246 }
247
248 mod propose_remove_members {
249 use super::*;
250
251 #[apply(all_cred_cipher)]
252 #[wasm_bindgen_test]
253 async fn can_propose_removing_members_from_conversation(case: TestCase) {
254 run_test_with_client_ids(
255 case.clone(),
256 ["alice", "bob", "charlie"],
257 move |[mut alice_central, bob_central, charlie_central]| {
258 Box::pin(async move {
259 let id = conversation_id();
260 alice_central
261 .context
262 .new_conversation(&id, case.credential_type, case.cfg.clone())
263 .await
264 .unwrap();
265 alice_central
266 .invite_all(&case, &id, [&bob_central, &charlie_central])
267 .await
268 .unwrap();
269
270 assert!(alice_central.pending_proposals(&id).await.is_empty());
271 let proposal = alice_central
272 .context
273 .new_remove_proposal(&id, charlie_central.get_client_id().await)
274 .await
275 .unwrap()
276 .proposal;
277 assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
278 bob_central
279 .context
280 .conversation(&id)
281 .await
282 .unwrap()
283 .decrypt_message(proposal.to_bytes().unwrap())
284 .await
285 .unwrap();
286 bob_central
287 .context
288 .conversation(&id)
289 .await
290 .unwrap()
291 .commit_pending_proposals()
292 .await
293 .unwrap();
294 let commit = bob_central.mls_transport.latest_commit().await;
295 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
296
297 alice_central
300 .context
301 .conversation(&id)
302 .await
303 .unwrap()
304 .decrypt_message(commit.to_bytes().unwrap())
305 .await
306 .unwrap();
307 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
308 })
309 },
310 )
311 .await
312 }
313 }
314
315 mod propose_self_update {
316 use super::*;
317
318 #[apply(all_cred_cipher)]
319 #[wasm_bindgen_test]
320 async fn can_propose_updating(case: TestCase) {
321 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
322 Box::pin(async move {
323 let id = conversation_id();
324 alice_central
325 .context
326 .new_conversation(&id, case.credential_type, case.cfg.clone())
327 .await
328 .unwrap();
329 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
330
331 let bob_keys = bob_central
332 .get_conversation_unchecked(&id)
333 .await
334 .signature_keys()
335 .collect::<Vec<SignaturePublicKey>>();
336 let alice_keys = alice_central
337 .get_conversation_unchecked(&id)
338 .await
339 .signature_keys()
340 .collect::<Vec<SignaturePublicKey>>();
341 assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
342 let alice_key = alice_central
343 .encryption_key_of(&id, alice_central.get_client_id().await)
344 .await;
345
346 let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
347 bob_central
348 .context
349 .conversation(&id)
350 .await
351 .unwrap()
352 .decrypt_message(proposal.to_bytes().unwrap())
353 .await
354 .unwrap();
355 bob_central
356 .context
357 .conversation(&id)
358 .await
359 .unwrap()
360 .commit_pending_proposals()
361 .await
362 .unwrap();
363 let commit = bob_central.mls_transport.latest_commit().await;
364
365 assert!(
366 !bob_central
367 .get_conversation_unchecked(&id)
368 .await
369 .encryption_keys()
370 .contains(&alice_key)
371 );
372
373 assert!(
374 alice_central
375 .get_conversation_unchecked(&id)
376 .await
377 .encryption_keys()
378 .contains(&alice_key)
379 );
380 alice_central
383 .context
384 .conversation(&id)
385 .await
386 .unwrap()
387 .decrypt_message(commit.to_bytes().unwrap())
388 .await
389 .unwrap();
390 assert!(
391 !alice_central
392 .get_conversation_unchecked(&id)
393 .await
394 .encryption_keys()
395 .contains(&alice_key)
396 );
397
398 assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
400 })
401 })
402 .await;
403 }
404 }
405
406 mod delivery_semantics {
407 use super::*;
408
409 #[apply(all_cred_cipher)]
410 #[wasm_bindgen_test]
411 async fn should_prevent_out_of_order_proposals(case: TestCase) {
412 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
413 Box::pin(async move {
414 let id = conversation_id();
415 alice_central
416 .context
417 .new_conversation(&id, case.credential_type, case.cfg.clone())
418 .await
419 .unwrap();
420 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
421
422 let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
423
424 bob_central
425 .context
426 .conversation(&id)
427 .await
428 .unwrap()
429 .decrypt_message(&proposal.to_bytes().unwrap())
430 .await
431 .unwrap();
432 bob_central
433 .context
434 .conversation(&id)
435 .await
436 .unwrap()
437 .commit_pending_proposals()
438 .await
439 .unwrap();
440 let past_proposal = bob_central
444 .context
445 .conversation(&id)
446 .await
447 .unwrap()
448 .decrypt_message(&proposal.to_bytes().unwrap())
449 .await;
450 assert!(matches!(past_proposal.unwrap_err(), Error::StaleProposal));
451 })
452 })
453 .await;
454 }
455 }
456}