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::init_certificates::NewCrlDistributionPoint,
16 mls::credential::crl::{extract_crl_uris_from_credentials, get_new_crl_distribution_points},
17 prelude::{Client, MlsConversation, MlsProposalRef},
18};
19
20impl MlsConversation {
22 #[cfg_attr(test, crate::durable)]
24 pub async fn propose_add_member(
25 &mut self,
26 client: &Client,
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: &Client,
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: &Client,
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: &Client,
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
106 .find_most_recent_credential_bundle(client)
107 .await
108 .map_err(RecursiveError::mls_client("finding most recent credential bundle"))?
109 .signature_key;
110
111 self.group
112 .propose_explicit_self_update(backend, msg_signer, leaf_node, leaf_node_signer)
113 .await
114 } else {
115 self.group.propose_self_update(backend, msg_signer).await
116 }
117 .map(MlsProposalBundle::from)
118 .map_err(MlsError::wrap("proposing self update"))?;
119
120 self.persist_group_when_changed(&backend.keystore(), false).await?;
121 Ok(proposal)
122 }
123}
124
125#[derive(Debug)]
127pub struct MlsProposalBundle {
128 pub proposal: MlsMessageOut,
130 pub proposal_ref: MlsProposalRef,
132 pub crl_new_distribution_points: NewCrlDistributionPoint,
134}
135
136impl From<(MlsMessageOut, openmls::prelude::hash_ref::ProposalRef)> for MlsProposalBundle {
137 fn from((proposal, proposal_ref): (MlsMessageOut, openmls::prelude::hash_ref::ProposalRef)) -> Self {
138 Self {
139 proposal,
140 proposal_ref: proposal_ref.into(),
141 crl_new_distribution_points: None.into(),
142 }
143 }
144}
145
146impl MlsProposalBundle {
147 #[allow(clippy::type_complexity)]
151 pub fn to_bytes(self) -> Result<(Vec<u8>, Vec<u8>, NewCrlDistributionPoint)> {
152 use openmls::prelude::TlsSerializeTrait as _;
153 let proposal = self
154 .proposal
155 .tls_serialize_detached()
156 .map_err(Error::tls_serialize("proposal"))?;
157 let proposal_ref = self.proposal_ref.to_bytes();
158
159 Ok((proposal, proposal_ref, self.crl_new_distribution_points))
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use itertools::Itertools;
166 use openmls::prelude::SignaturePublicKey;
167 use wasm_bindgen_test::*;
168
169 use crate::test_utils::*;
170
171 use super::*;
172
173 wasm_bindgen_test_configure!(run_in_browser);
174
175 mod propose_add_members {
176 use super::*;
177
178 #[apply(all_cred_cipher)]
179 #[wasm_bindgen_test]
180 async fn can_propose_adding_members_to_conversation(case: TestCase) {
181 run_test_with_client_ids(
182 case.clone(),
183 ["alice", "bob", "charlie"],
184 move |[mut alice_central, bob_central, mut charlie_central]| {
185 Box::pin(async move {
186 let id = conversation_id();
187 alice_central
188 .context
189 .new_conversation(&id, case.credential_type, case.cfg.clone())
190 .await
191 .unwrap();
192 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
193 let charlie_kp = charlie_central.get_one_key_package(&case).await;
194
195 assert!(alice_central.pending_proposals(&id).await.is_empty());
196 let proposal = alice_central
197 .context
198 .new_add_proposal(&id, charlie_kp)
199 .await
200 .unwrap()
201 .proposal;
202 assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
203 bob_central
204 .context
205 .conversation(&id)
206 .await
207 .unwrap()
208 .decrypt_message(proposal.to_bytes().unwrap())
209 .await
210 .unwrap();
211 bob_central
212 .context
213 .conversation(&id)
214 .await
215 .unwrap()
216 .commit_pending_proposals()
217 .await
218 .unwrap();
219 let commit = bob_central.mls_transport.latest_commit().await;
220 let welcome = bob_central.mls_transport.latest_welcome_message().await;
221 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 3);
222
223 alice_central
226 .context
227 .conversation(&id)
228 .await
229 .unwrap()
230 .decrypt_message(commit.to_bytes().unwrap())
231 .await
232 .unwrap();
233 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 3);
234
235 charlie_central
236 .try_join_from_welcome(
237 &id,
238 welcome.into(),
239 case.custom_cfg(),
240 vec![&alice_central, &bob_central],
241 )
242 .await
243 .unwrap();
244 assert_eq!(charlie_central.get_conversation_unchecked(&id).await.members().len(), 3);
245 })
246 },
247 )
248 .await
249 }
250 }
251
252 mod propose_remove_members {
253 use super::*;
254
255 #[apply(all_cred_cipher)]
256 #[wasm_bindgen_test]
257 async fn can_propose_removing_members_from_conversation(case: TestCase) {
258 run_test_with_client_ids(
259 case.clone(),
260 ["alice", "bob", "charlie"],
261 move |[mut alice_central, bob_central, charlie_central]| {
262 Box::pin(async move {
263 let id = conversation_id();
264 alice_central
265 .context
266 .new_conversation(&id, case.credential_type, case.cfg.clone())
267 .await
268 .unwrap();
269 alice_central
270 .invite_all(&case, &id, [&bob_central, &charlie_central])
271 .await
272 .unwrap();
273
274 assert!(alice_central.pending_proposals(&id).await.is_empty());
275 let proposal = alice_central
276 .context
277 .new_remove_proposal(&id, charlie_central.get_client_id().await)
278 .await
279 .unwrap()
280 .proposal;
281 assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
282 bob_central
283 .context
284 .conversation(&id)
285 .await
286 .unwrap()
287 .decrypt_message(proposal.to_bytes().unwrap())
288 .await
289 .unwrap();
290 bob_central
291 .context
292 .conversation(&id)
293 .await
294 .unwrap()
295 .commit_pending_proposals()
296 .await
297 .unwrap();
298 let commit = bob_central.mls_transport.latest_commit().await;
299 assert_eq!(bob_central.get_conversation_unchecked(&id).await.members().len(), 2);
300
301 alice_central
304 .context
305 .conversation(&id)
306 .await
307 .unwrap()
308 .decrypt_message(commit.to_bytes().unwrap())
309 .await
310 .unwrap();
311 assert_eq!(alice_central.get_conversation_unchecked(&id).await.members().len(), 2);
312 })
313 },
314 )
315 .await
316 }
317 }
318
319 mod propose_self_update {
320 use super::*;
321
322 #[apply(all_cred_cipher)]
323 #[wasm_bindgen_test]
324 async fn can_propose_updating(case: TestCase) {
325 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
326 Box::pin(async move {
327 let id = conversation_id();
328 alice_central
329 .context
330 .new_conversation(&id, case.credential_type, case.cfg.clone())
331 .await
332 .unwrap();
333 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
334
335 let bob_keys = bob_central
336 .get_conversation_unchecked(&id)
337 .await
338 .signature_keys()
339 .collect::<Vec<SignaturePublicKey>>();
340 let alice_keys = alice_central
341 .get_conversation_unchecked(&id)
342 .await
343 .signature_keys()
344 .collect::<Vec<SignaturePublicKey>>();
345 assert!(alice_keys.iter().all(|a_key| bob_keys.contains(a_key)));
346 let alice_key = alice_central
347 .encryption_key_of(&id, alice_central.get_client_id().await)
348 .await;
349
350 let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
351 bob_central
352 .context
353 .conversation(&id)
354 .await
355 .unwrap()
356 .decrypt_message(proposal.to_bytes().unwrap())
357 .await
358 .unwrap();
359 bob_central
360 .context
361 .conversation(&id)
362 .await
363 .unwrap()
364 .commit_pending_proposals()
365 .await
366 .unwrap();
367 let commit = bob_central.mls_transport.latest_commit().await;
368
369 assert!(
370 !bob_central
371 .get_conversation_unchecked(&id)
372 .await
373 .encryption_keys()
374 .contains(&alice_key)
375 );
376
377 assert!(
378 alice_central
379 .get_conversation_unchecked(&id)
380 .await
381 .encryption_keys()
382 .contains(&alice_key)
383 );
384 alice_central
387 .context
388 .conversation(&id)
389 .await
390 .unwrap()
391 .decrypt_message(commit.to_bytes().unwrap())
392 .await
393 .unwrap();
394 assert!(
395 !alice_central
396 .get_conversation_unchecked(&id)
397 .await
398 .encryption_keys()
399 .contains(&alice_key)
400 );
401
402 assert!(alice_central.try_talk_to(&id, &bob_central).await.is_ok());
404 })
405 })
406 .await;
407 }
408 }
409
410 mod delivery_semantics {
411 use super::*;
412
413 #[apply(all_cred_cipher)]
414 #[wasm_bindgen_test]
415 async fn should_prevent_out_of_order_proposals(case: TestCase) {
416 run_test_with_client_ids(case.clone(), ["alice", "bob"], move |[alice_central, bob_central]| {
417 Box::pin(async move {
418 let id = conversation_id();
419 alice_central
420 .context
421 .new_conversation(&id, case.credential_type, case.cfg.clone())
422 .await
423 .unwrap();
424 alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
425
426 let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
427
428 bob_central
429 .context
430 .conversation(&id)
431 .await
432 .unwrap()
433 .decrypt_message(&proposal.to_bytes().unwrap())
434 .await
435 .unwrap();
436 bob_central
437 .context
438 .conversation(&id)
439 .await
440 .unwrap()
441 .commit_pending_proposals()
442 .await
443 .unwrap();
444 let past_proposal = bob_central
448 .context
449 .conversation(&id)
450 .await
451 .unwrap()
452 .decrypt_message(&proposal.to_bytes().unwrap())
453 .await;
454 assert!(matches!(past_proposal.unwrap_err(), Error::StaleProposal));
455 })
456 })
457 .await;
458 }
459 }
460}