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: TestContext) {
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 .transaction
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 .transaction
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 .transaction
201 .conversation(&id)
202 .await
203 .unwrap()
204 .decrypt_message(proposal.to_bytes().unwrap())
205 .await
206 .unwrap();
207 bob_central
208 .transaction
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 .transaction
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: TestContext) {
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 .transaction
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 .transaction
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 .transaction
280 .conversation(&id)
281 .await
282 .unwrap()
283 .decrypt_message(proposal.to_bytes().unwrap())
284 .await
285 .unwrap();
286 bob_central
287 .transaction
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 .transaction
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: TestContext) {
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 .transaction
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
347 .transaction
348 .new_update_proposal(&id)
349 .await
350 .unwrap()
351 .proposal;
352 bob_central
353 .transaction
354 .conversation(&id)
355 .await
356 .unwrap()
357 .decrypt_message(proposal.to_bytes().unwrap())
358 .await
359 .unwrap();
360 bob_central
361 .transaction
362 .conversation(&id)
363 .await
364 .unwrap()
365 .commit_pending_proposals()
366 .await
367 .unwrap();
368 let commit = bob_central.mls_transport.latest_commit().await;
369
370 assert!(
371 !bob_central
372 .get_conversation_unchecked(&id)
373 .await
374 .encryption_keys()
375 .contains(&alice_key)
376 );
377
378 assert!(
379 alice_central
380 .get_conversation_unchecked(&id)
381 .await
382 .encryption_keys()
383 .contains(&alice_key)
384 );
385 alice_central
388 .transaction
389 .conversation(&id)
390 .await
391 .unwrap()
392 .decrypt_message(commit.to_bytes().unwrap())
393 .await
394 .unwrap();
395 assert!(
396 !alice_central
397 .get_conversation_unchecked(&id)
398 .await
399 .encryption_keys()
400 .contains(&alice_key)
401 );
402
403 assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
405 })
406 })
407 .await;
408 }
409 }
410
411 mod delivery_semantics {
412 use super::*;
413
414 #[apply(all_cred_cipher)]
415 #[wasm_bindgen_test]
416 async fn should_prevent_out_of_order_proposals(case: TestContext) {
417 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
418 Box::pin(async move {
419 let id = conversation_id();
420 alice_central
421 .transaction
422 .new_conversation(&id, case.credential_type, case.cfg.clone())
423 .await
424 .unwrap();
425 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
426
427 let proposal = alice_central
428 .transaction
429 .new_update_proposal(&id)
430 .await
431 .unwrap()
432 .proposal;
433
434 bob_central
435 .transaction
436 .conversation(&id)
437 .await
438 .unwrap()
439 .decrypt_message(&proposal.to_bytes().unwrap())
440 .await
441 .unwrap();
442 bob_central
443 .transaction
444 .conversation(&id)
445 .await
446 .unwrap()
447 .commit_pending_proposals()
448 .await
449 .unwrap();
450 let past_proposal = bob_central
454 .transaction
455 .conversation(&id)
456 .await
457 .unwrap()
458 .decrypt_message(&proposal.to_bytes().unwrap())
459 .await;
460 assert!(matches!(past_proposal.unwrap_err(), Error::StaleProposal));
461 })
462 })
463 .await;
464 }
465 }
466}