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