core_crypto/mls/conversation/
renew.rs

1use core_crypto_keystore::entities::MlsEncryptionKeyPair;
2use openmls::prelude::{LeafNode, LeafNodeIndex, Proposal, QueuedProposal, Sender, StagedCommit};
3use openmls_traits::OpenMlsCryptoProvider;
4
5use mls_crypto_provider::MlsCryptoProvider;
6
7use super::{Error, Result};
8use crate::{
9    KeystoreError, RecursiveError,
10    prelude::{MlsConversation, MlsProposalBundle, Session},
11};
12
13/// Marker struct holding methods responsible for restoring (renewing) proposals (or pending commit)
14/// in case another commit has been accepted by the backend instead of ours
15pub(crate) struct Renew;
16
17impl Renew {
18    /// Renews proposals:
19    /// * in pending_proposals but not in valid commit
20    /// * in pending_commit but not in valid commit
21    ///
22    /// NB: we do not deal with partial commit (commit which do not contain all pending proposals)
23    /// because they cannot be created at the moment by core-crypto
24    ///
25    /// * `self_index` - own client [KeyPackageRef] in current MLS group
26    /// * `pending_proposals` - local pending proposals in group's proposal store
27    /// * `pending_commit` - local pending commit which is now invalid
28    /// * `valid_commit` - commit accepted by the backend which will now supersede our local pending commit
29    pub(crate) fn renew<'a>(
30        self_index: &LeafNodeIndex,
31        pending_proposals: impl Iterator<Item = QueuedProposal> + 'a,
32        pending_commit: Option<&'a StagedCommit>,
33        valid_commit: &'a StagedCommit,
34    ) -> (Vec<QueuedProposal>, bool) {
35        // indicates if we need to renew an update proposal.
36        // true only if we have an empty pending commit or the valid commit does not contain one of our update proposal
37        // otherwise, local orphan update proposal will be renewed regularly, without this flag
38        let mut needs_update = false;
39
40        let renewed_pending_proposals = if let Some(pending_commit) = pending_commit {
41            // present in pending commit but not in valid commit
42            let commit_proposals = pending_commit.queued_proposals().cloned().collect::<Vec<_>>();
43
44            // if our own pending commit is empty it means we were attempting to update
45            let empty_commit = commit_proposals.is_empty();
46
47            // does the valid commit contains one of our update proposal ?
48            let valid_commit_has_own_update_proposal = valid_commit.update_proposals().any(|p| match p.sender() {
49                Sender::Member(sender_index) => self_index == sender_index,
50                _ => false,
51            });
52
53            // do we need to renew the update or has it already been committed
54            needs_update = !valid_commit_has_own_update_proposal && empty_commit;
55
56            // local proposals present in local pending commit but not in valid commit
57            commit_proposals
58                .into_iter()
59                .filter_map(|p| Self::is_proposal_renewable(p, Some(valid_commit)))
60                .collect::<Vec<_>>()
61        } else {
62            // local pending proposals present locally but not in valid commit
63            pending_proposals
64                .filter_map(|p| Self::is_proposal_renewable(p, Some(valid_commit)))
65                .collect::<Vec<_>>()
66        };
67        (renewed_pending_proposals, needs_update)
68    }
69
70    /// A proposal has to be renewed if it is absent from supplied commit
71    fn is_proposal_renewable(proposal: QueuedProposal, commit: Option<&StagedCommit>) -> Option<QueuedProposal> {
72        if let Some(commit) = commit {
73            let in_commit = match proposal.proposal() {
74                Proposal::Add(add) => commit.add_proposals().any(|p| {
75                    let commits_identity = p.add_proposal().key_package().leaf_node().credential().identity();
76                    let proposal_identity = add.key_package().leaf_node().credential().identity();
77                    commits_identity == proposal_identity
78                }),
79                Proposal::Remove(remove) => commit
80                    .remove_proposals()
81                    .any(|p| p.remove_proposal().removed() == remove.removed()),
82                Proposal::Update(update) => commit
83                    .update_proposals()
84                    .any(|p| p.update_proposal().leaf_node() == update.leaf_node()),
85                _ => true,
86            };
87            if in_commit { None } else { Some(proposal) }
88        } else {
89            // if proposal is orphan (not present in commit)
90            Some(proposal)
91        }
92    }
93}
94
95impl MlsConversation {
96    /// Given the proposals to renew, actually restore them by using associated methods in [MlsGroup].
97    /// This will also add them to the local proposal store
98    pub(crate) async fn renew_proposals_for_current_epoch(
99        &mut self,
100        client: &Session,
101        backend: &MlsCryptoProvider,
102        proposals: impl Iterator<Item = QueuedProposal>,
103        needs_update: bool,
104    ) -> Result<Vec<MlsProposalBundle>> {
105        let mut bundle = vec![];
106        let is_external = |p: &QueuedProposal| matches!(p.sender(), Sender::External(_) | Sender::NewMemberProposal);
107        let proposals = proposals.filter(|p| !is_external(p));
108        for proposal in proposals {
109            let msg = match proposal.proposal {
110                Proposal::Add(add) => self.propose_add_member(client, backend, add.key_package.into()).await?,
111                Proposal::Remove(remove) => self.propose_remove_member(client, backend, remove.removed()).await?,
112                Proposal::Update(update) => self.renew_update(client, backend, Some(update.leaf_node())).await?,
113                _ => return Err(Error::ProposalVariantCannotBeRenewed),
114            };
115            bundle.push(msg);
116        }
117        if needs_update {
118            let proposal = self.renew_update(client, backend, None).await?;
119            bundle.push(proposal);
120        }
121        Ok(bundle)
122    }
123
124    /// Renews an update proposal by considering the explicit LeafNode supplied in the proposal
125    /// by applying it to the current own LeafNode.
126    /// At this point, we have already verified we are only operating on proposals created by self.
127    async fn renew_update(
128        &mut self,
129        client: &Session,
130        backend: &MlsCryptoProvider,
131        leaf_node: Option<&LeafNode>,
132    ) -> Result<MlsProposalBundle> {
133        if let Some(leaf_node) = leaf_node {
134            // Creating an update rekeys the LeafNode everytime. Hence we need to clear the previous
135            // encryption key from the keystore otherwise we would have a leak
136            backend
137                .key_store()
138                .remove::<MlsEncryptionKeyPair, _>(leaf_node.encryption_key().as_slice())
139                .await
140                .map_err(KeystoreError::wrap("removing mls encryption keypair"))?;
141        }
142
143        let mut leaf_node = leaf_node
144            .or_else(|| self.group.own_leaf())
145            .cloned()
146            .ok_or(Error::MlsGroupInvalidState("own_leaf is None"))?;
147
148        let sc = self.signature_scheme();
149        let ct = self.own_credential_type()?;
150        let cb = client
151            .find_most_recent_credential_bundle(sc, ct)
152            .await
153            .map_err(RecursiveError::mls_client("finding most recent credential bundle"))?;
154
155        leaf_node.set_credential_with_key(cb.to_mls_credential_with_key());
156
157        self.propose_explicit_self_update(client, backend, Some(leaf_node))
158            .await
159    }
160
161    pub(crate) fn self_pending_proposals(&self) -> impl Iterator<Item = &QueuedProposal> {
162        self.group
163            .pending_proposals()
164            .filter(|&p| matches!(p.sender(), Sender::Member(i) if i == &self.group.own_leaf_index()))
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use wasm_bindgen_test::*;
171
172    use crate::test_utils::*;
173
174    mod update {
175        use super::*;
176
177        #[apply(all_cred_cipher)]
178        #[wasm_bindgen_test]
179        pub async fn renewable_when_created_by_self(case: TestContext) {
180            let [mut alice_central, bob_central] = case.sessions().await;
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
190                assert!(alice_central.pending_proposals(&id).await.is_empty());
191                alice_central.transaction.new_update_proposal(&id).await.unwrap();
192                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
193
194                // Bob hasn't Alice's proposal but creates a commit
195                bob_central
196                    .transaction
197                    .conversation(&id)
198                    .await
199                    .unwrap()
200                    .update_key_material()
201                    .await
202                    .unwrap();
203                let commit = bob_central.mls_transport().await.latest_commit().await;
204
205                let proposals = alice_central
206                    .transaction
207                    .conversation(&id)
208                    .await
209                    .unwrap()
210                    .decrypt_message(commit.to_bytes().unwrap())
211                    .await
212                    .unwrap()
213                    .proposals;
214                // Alice should renew the proposal because its hers
215                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
216                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
217
218                // It should also renew the proposal when in pending_commit
219                bob_central
220                    .transaction
221                    .conversation(&id)
222                    .await
223                    .unwrap()
224                    .update_key_material()
225                    .await
226                    .unwrap();
227                let commit = bob_central.mls_transport().await.latest_commit().await;
228                let proposals = alice_central
229                    .transaction
230                    .conversation(&id)
231                    .await
232                    .unwrap()
233                    .decrypt_message(commit.to_bytes().unwrap())
234                    .await
235                    .unwrap()
236                    .proposals;
237                // Alice should renew the proposal because its hers
238                // It should also replace existing one
239                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
240                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
241            })
242            .await
243        }
244
245        #[apply(all_cred_cipher)]
246        #[wasm_bindgen_test]
247        pub async fn not_renewable_when_in_valid_commit(case: TestContext) {
248            let [mut alice_central, bob_central] = case.sessions().await;
249            Box::pin(async move {
250                let id = conversation_id();
251                alice_central
252                    .transaction
253                    .new_conversation(&id, case.credential_type, case.cfg.clone())
254                    .await
255                    .unwrap();
256                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
257
258                assert!(alice_central.pending_proposals(&id).await.is_empty());
259                let proposal = alice_central
260                    .transaction
261                    .new_update_proposal(&id)
262                    .await
263                    .unwrap()
264                    .proposal;
265                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
266
267                // Bob has Alice's update proposal
268                bob_central
269                    .transaction
270                    .conversation(&id)
271                    .await
272                    .unwrap()
273                    .decrypt_message(proposal.to_bytes().unwrap())
274                    .await
275                    .unwrap();
276
277                bob_central
278                    .transaction
279                    .conversation(&id)
280                    .await
281                    .unwrap()
282                    .update_key_material()
283                    .await
284                    .unwrap();
285                let commit = bob_central.mls_transport().await.latest_commit().await;
286
287                // Bob's commit has Alice's proposal
288                let proposals = alice_central
289                    .transaction
290                    .conversation(&id)
291                    .await
292                    .unwrap()
293                    .decrypt_message(commit.to_bytes().unwrap())
294                    .await
295                    .unwrap()
296                    .proposals;
297                // Alice proposal should not be renew as it was in valid commit
298                assert!(alice_central.pending_proposals(&id).await.is_empty());
299                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
300
301                let proposal = alice_central
302                    .transaction
303                    .new_update_proposal(&id)
304                    .await
305                    .unwrap()
306                    .proposal;
307                bob_central
308                    .transaction
309                    .conversation(&id)
310                    .await
311                    .unwrap()
312                    .decrypt_message(proposal.to_bytes().unwrap())
313                    .await
314                    .unwrap();
315                bob_central
316                    .transaction
317                    .conversation(&id)
318                    .await
319                    .unwrap()
320                    .update_key_material()
321                    .await
322                    .unwrap();
323                let commit = bob_central.mls_transport().await.latest_commit().await;
324                let proposals = alice_central
325                    .transaction
326                    .conversation(&id)
327                    .await
328                    .unwrap()
329                    .decrypt_message(commit.to_bytes().unwrap())
330                    .await
331                    .unwrap()
332                    .proposals;
333                // Alice should not be renew as it was in valid commit
334                assert!(alice_central.pending_proposals(&id).await.is_empty());
335                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
336            })
337            .await
338        }
339
340        #[apply(all_cred_cipher)]
341        #[wasm_bindgen_test]
342        pub async fn not_renewable_by_ref(case: TestContext) {
343            let [mut alice_central, bob_central, charlie_central] = case.sessions().await;
344            Box::pin(async move {
345                let id = conversation_id();
346                alice_central
347                    .transaction
348                    .new_conversation(&id, case.credential_type, case.cfg.clone())
349                    .await
350                    .unwrap();
351                alice_central
352                    .invite_all(&case, &id, [&bob_central, &charlie_central])
353                    .await
354                    .unwrap();
355
356                let proposal = bob_central.transaction.new_update_proposal(&id).await.unwrap().proposal;
357                assert!(alice_central.pending_proposals(&id).await.is_empty());
358                alice_central
359                    .transaction
360                    .conversation(&id)
361                    .await
362                    .unwrap()
363                    .decrypt_message(proposal.to_bytes().unwrap())
364                    .await
365                    .unwrap();
366                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
367
368                // Charlie does not have other proposals, it creates a commit
369                charlie_central
370                    .transaction
371                    .conversation(&id)
372                    .await
373                    .unwrap()
374                    .update_key_material()
375                    .await
376                    .unwrap();
377                let commit = charlie_central.mls_transport().await.latest_commit().await;
378                let proposals = alice_central
379                    .transaction
380                    .conversation(&id)
381                    .await
382                    .unwrap()
383                    .decrypt_message(commit.to_bytes().unwrap())
384                    .await
385                    .unwrap()
386                    .proposals;
387                // Alice should not renew Bob's update proposal
388                assert!(alice_central.pending_proposals(&id).await.is_empty());
389                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
390            })
391            .await
392        }
393    }
394
395    mod add {
396        use super::*;
397
398        #[apply(all_cred_cipher)]
399        #[wasm_bindgen_test]
400        pub async fn not_renewable_when_valid_commit_adds_same(case: TestContext) {
401            let [mut alice_central, bob_central, charlie_central] = case.sessions().await;
402            Box::pin(async move {
403                let id = conversation_id();
404                alice_central
405                    .transaction
406                    .new_conversation(&id, case.credential_type, case.cfg.clone())
407                    .await
408                    .unwrap();
409                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
410
411                let charlie_kp = charlie_central.get_one_key_package(&case).await;
412                assert!(alice_central.pending_proposals(&id).await.is_empty());
413                alice_central
414                    .transaction
415                    .new_add_proposal(&id, charlie_kp)
416                    .await
417                    .unwrap();
418                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
419
420                let charlie = charlie_central.rand_key_package(&case).await;
421                bob_central
422                    .transaction
423                    .conversation(&id)
424                    .await
425                    .unwrap()
426                    .add_members(vec![charlie])
427                    .await
428                    .unwrap();
429                let commit = bob_central.mls_transport().await.latest_commit().await;
430                let proposals = alice_central
431                    .transaction
432                    .conversation(&id)
433                    .await
434                    .unwrap()
435                    .decrypt_message(commit.to_bytes().unwrap())
436                    .await
437                    .unwrap()
438                    .proposals;
439                // Alice proposal is not renewed since she also wanted to add Charlie
440                assert!(alice_central.pending_proposals(&id).await.is_empty());
441                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
442            })
443            .await
444        }
445
446        #[apply(all_cred_cipher)]
447        #[wasm_bindgen_test]
448        pub async fn not_renewable_in_pending_commit_when_valid_commit_adds_same(case: TestContext) {
449            let [mut alice_central, bob_central, charlie_central] = case.sessions().await;
450            Box::pin(async move {
451                let id = conversation_id();
452                alice_central
453                    .transaction
454                    .new_conversation(&id, case.credential_type, case.cfg.clone())
455                    .await
456                    .unwrap();
457                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
458
459                let charlie_kp = charlie_central.get_one_key_package(&case).await;
460                assert!(alice_central.pending_proposals(&id).await.is_empty());
461                alice_central
462                    .transaction
463                    .new_add_proposal(&id, charlie_kp)
464                    .await
465                    .unwrap();
466                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
467
468                // Here Alice also creates a commit
469                alice_central.commit_pending_proposals_unmerged(&id).await;
470                assert!(alice_central.pending_commit(&id).await.is_some());
471
472                let charlie = charlie_central.rand_key_package(&case).await;
473                bob_central
474                    .transaction
475                    .conversation(&id)
476                    .await
477                    .unwrap()
478                    .add_members(vec![charlie])
479                    .await
480                    .unwrap();
481                let commit = bob_central.mls_transport().await.latest_commit().await;
482                let proposals = alice_central
483                    .transaction
484                    .conversation(&id)
485                    .await
486                    .unwrap()
487                    .decrypt_message(commit.to_bytes().unwrap())
488                    .await
489                    .unwrap()
490                    .proposals;
491                // Alice proposal is not renewed since she also wanted to add Charlie
492                assert!(alice_central.pending_proposals(&id).await.is_empty());
493                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
494            })
495            .await
496        }
497
498        #[apply(all_cred_cipher)]
499        #[wasm_bindgen_test]
500        pub async fn not_renewable_by_ref(case: TestContext) {
501            let [mut alice_central, bob_central, charlie_central, debbie_central] = case.sessions().await;
502            Box::pin(async move {
503                let id = conversation_id();
504                alice_central
505                    .transaction
506                    .new_conversation(&id, case.credential_type, case.cfg.clone())
507                    .await
508                    .unwrap();
509                alice_central
510                    .invite_all(&case, &id, [&bob_central, &charlie_central])
511                    .await
512                    .unwrap();
513
514                // Bob will propose adding Debbie
515                let debbie_kp = debbie_central.get_one_key_package(&case).await;
516                let proposal = bob_central
517                    .transaction
518                    .new_add_proposal(&id, debbie_kp)
519                    .await
520                    .unwrap()
521                    .proposal;
522                alice_central
523                    .transaction
524                    .conversation(&id)
525                    .await
526                    .unwrap()
527                    .decrypt_message(proposal.to_bytes().unwrap())
528                    .await
529                    .unwrap();
530                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
531
532                // But Charlie will commit meanwhile
533                charlie_central
534                    .transaction
535                    .conversation(&id)
536                    .await
537                    .unwrap()
538                    .update_key_material()
539                    .await
540                    .unwrap();
541                let commit = charlie_central.mls_transport().await.latest_commit().await;
542                let proposals = alice_central
543                    .transaction
544                    .conversation(&id)
545                    .await
546                    .unwrap()
547                    .decrypt_message(commit.to_bytes().unwrap())
548                    .await
549                    .unwrap()
550                    .proposals;
551                // which Alice should not renew since it's not hers
552                assert!(alice_central.pending_proposals(&id).await.is_empty());
553                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
554            })
555            .await
556        }
557
558        #[apply(all_cred_cipher)]
559        #[wasm_bindgen_test]
560        pub async fn renewable_when_valid_commit_doesnt_adds_same(case: TestContext) {
561            let [mut alice_central, bob_central, charlie_central] = case.sessions().await;
562            Box::pin(async move {
563                let id = conversation_id();
564                alice_central
565                    .transaction
566                    .new_conversation(&id, case.credential_type, case.cfg.clone())
567                    .await
568                    .unwrap();
569                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
570
571                // Alice proposes adding Charlie
572                let charlie_kp = charlie_central.get_one_key_package(&case).await;
573                assert!(alice_central.pending_proposals(&id).await.is_empty());
574                alice_central
575                    .transaction
576                    .new_add_proposal(&id, charlie_kp)
577                    .await
578                    .unwrap();
579                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
580
581                // But meanwhile Bob will create a commit without Alice's proposal
582                bob_central
583                    .transaction
584                    .conversation(&id)
585                    .await
586                    .unwrap()
587                    .update_key_material()
588                    .await
589                    .unwrap();
590                let commit = bob_central.mls_transport().await.latest_commit().await;
591                let proposals = alice_central
592                    .transaction
593                    .conversation(&id)
594                    .await
595                    .unwrap()
596                    .decrypt_message(commit.to_bytes().unwrap())
597                    .await
598                    .unwrap()
599                    .proposals;
600                // So Alice proposal should be renewed
601                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
602                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
603
604                // And same should happen when proposal is in pending commit
605                alice_central.commit_pending_proposals_unmerged(&id).await;
606                assert!(alice_central.pending_commit(&id).await.is_some());
607                bob_central
608                    .transaction
609                    .conversation(&id)
610                    .await
611                    .unwrap()
612                    .update_key_material()
613                    .await
614                    .unwrap();
615                let commit = bob_central.mls_transport().await.latest_commit().await;
616                let proposals = alice_central
617                    .transaction
618                    .conversation(&id)
619                    .await
620                    .unwrap()
621                    .decrypt_message(commit.to_bytes().unwrap())
622                    .await
623                    .unwrap()
624                    .proposals;
625                // So Alice proposal should also be renewed
626                // It should also replace existing one
627                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
628                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
629            })
630            .await
631        }
632
633        #[apply(all_cred_cipher)]
634        #[wasm_bindgen_test]
635        pub async fn renews_pending_commit_when_valid_commit_doesnt_add_same(case: TestContext) {
636            let [mut alice_central, bob_central] = case.sessions().await;
637            Box::pin(async move {
638                let id = conversation_id();
639                alice_central
640                    .transaction
641                    .new_conversation(&id, case.credential_type, case.cfg.clone())
642                    .await
643                    .unwrap();
644                alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
645
646                // Alice commits adding Charlie
647                alice_central.create_unmerged_commit(&id).await;
648                assert!(alice_central.pending_commit(&id).await.is_some());
649
650                // But meanwhile Bob will create a commit
651                bob_central
652                    .transaction
653                    .conversation(&id)
654                    .await
655                    .unwrap()
656                    .update_key_material()
657                    .await
658                    .unwrap();
659                let commit = bob_central.mls_transport().await.latest_commit().await;
660                let proposals = alice_central
661                    .transaction
662                    .conversation(&id)
663                    .await
664                    .unwrap()
665                    .decrypt_message(commit.to_bytes().unwrap())
666                    .await
667                    .unwrap()
668                    .proposals;
669                // So Alice proposal should be renewed
670                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
671                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
672            })
673            .await
674        }
675    }
676
677    mod remove {
678        use super::*;
679
680        #[apply(all_cred_cipher)]
681        #[wasm_bindgen_test]
682        pub async fn not_renewable_when_valid_commit_removes_same(case: TestContext) {
683            let [mut alice_central, bob_central, charlie_central] = case.sessions().await;
684            Box::pin(async move {
685                let id = conversation_id();
686                alice_central
687                    .transaction
688                    .new_conversation(&id, case.credential_type, case.cfg.clone())
689                    .await
690                    .unwrap();
691                alice_central
692                    .invite_all(&case, &id, [&bob_central, &charlie_central])
693                    .await
694                    .unwrap();
695
696                assert!(alice_central.pending_proposals(&id).await.is_empty());
697                alice_central
698                    .transaction
699                    .new_remove_proposal(&id, charlie_central.get_client_id().await)
700                    .await
701                    .unwrap();
702                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
703
704                bob_central
705                    .transaction
706                    .conversation(&id)
707                    .await
708                    .unwrap()
709                    .remove_members(&[charlie_central.get_client_id().await])
710                    .await
711                    .unwrap();
712                let commit = bob_central.mls_transport().await.latest_commit().await;
713                let proposals = alice_central
714                    .transaction
715                    .conversation(&id)
716                    .await
717                    .unwrap()
718                    .decrypt_message(commit.to_bytes().unwrap())
719                    .await
720                    .unwrap()
721                    .proposals;
722                // Remove proposal is not renewed since commit does same
723                assert!(alice_central.pending_proposals(&id).await.is_empty());
724                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
725            })
726            .await
727        }
728
729        #[apply(all_cred_cipher)]
730        #[wasm_bindgen_test]
731        pub async fn not_renewable_by_ref(case: TestContext) {
732            let [mut alice_central, bob_central, charlie_central] = case.sessions().await;
733            Box::pin(async move {
734                let id = conversation_id();
735                alice_central
736                    .transaction
737                    .new_conversation(&id, case.credential_type, case.cfg.clone())
738                    .await
739                    .unwrap();
740                alice_central
741                    .invite_all(&case, &id, [&bob_central, &charlie_central])
742                    .await
743                    .unwrap();
744
745                let proposal = bob_central
746                    .transaction
747                    .new_remove_proposal(&id, charlie_central.get_client_id().await)
748                    .await
749                    .unwrap()
750                    .proposal;
751                assert!(alice_central.pending_proposals(&id).await.is_empty());
752                alice_central
753                    .transaction
754                    .conversation(&id)
755                    .await
756                    .unwrap()
757                    .decrypt_message(proposal.to_bytes().unwrap())
758                    .await
759                    .unwrap();
760                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
761
762                charlie_central
763                    .transaction
764                    .conversation(&id)
765                    .await
766                    .unwrap()
767                    .update_key_material()
768                    .await
769                    .unwrap();
770                let commit = charlie_central.mls_transport().await.latest_commit().await;
771                let proposals = alice_central
772                    .transaction
773                    .conversation(&id)
774                    .await
775                    .unwrap()
776                    .decrypt_message(commit.to_bytes().unwrap())
777                    .await
778                    .unwrap()
779                    .proposals;
780                // Remove proposal is not renewed since by ref
781                assert!(alice_central.pending_proposals(&id).await.is_empty());
782                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
783            })
784            .await
785        }
786
787        #[apply(all_cred_cipher)]
788        #[wasm_bindgen_test]
789        pub async fn renewable_when_valid_commit_doesnt_remove_same(case: TestContext) {
790            let [mut alice_central, bob_central, charlie_central, debbie_central] = case.sessions().await;
791            Box::pin(async move {
792                let id = conversation_id();
793                alice_central
794                    .transaction
795                    .new_conversation(&id, case.credential_type, case.cfg.clone())
796                    .await
797                    .unwrap();
798                alice_central
799                    .invite_all(&case, &id, [&bob_central, &charlie_central, &debbie_central])
800                    .await
801                    .unwrap();
802
803                // Alice wants to remove Charlie
804                assert!(alice_central.pending_proposals(&id).await.is_empty());
805                alice_central
806                    .transaction
807                    .new_remove_proposal(&id, charlie_central.get_client_id().await)
808                    .await
809                    .unwrap();
810                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
811
812                // Whereas Bob wants to remove Debbie
813                bob_central
814                    .transaction
815                    .conversation(&id)
816                    .await
817                    .unwrap()
818                    .remove_members(&[debbie_central.get_client_id().await])
819                    .await
820                    .unwrap();
821                let commit = bob_central.mls_transport().await.latest_commit().await;
822                let proposals = alice_central
823                    .transaction
824                    .conversation(&id)
825                    .await
826                    .unwrap()
827                    .decrypt_message(commit.to_bytes().unwrap())
828                    .await
829                    .unwrap()
830                    .proposals;
831                // Remove is renewed since valid commit removes another
832                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
833                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
834            })
835            .await
836        }
837
838        #[apply(all_cred_cipher)]
839        #[wasm_bindgen_test]
840        pub async fn renews_pending_commit_when_commit_doesnt_remove_same(case: TestContext) {
841            let [mut alice_central, bob_central, charlie_central, debbie_central] = case.sessions().await;
842            Box::pin(async move {
843                let id = conversation_id();
844                alice_central
845                    .transaction
846                    .new_conversation(&id, case.credential_type, case.cfg.clone())
847                    .await
848                    .unwrap();
849                alice_central
850                    .invite_all(&case, &id, [&bob_central, &charlie_central, &debbie_central])
851                    .await
852                    .unwrap();
853
854                // Alice wants to remove Charlie
855                alice_central
856                    .transaction
857                    .new_remove_proposal(&id, charlie_central.get_client_id().await)
858                    .await
859                    .unwrap();
860                alice_central.commit_pending_proposals_unmerged(&id).await;
861                assert!(alice_central.pending_commit(&id).await.is_some());
862
863                // Whereas Bob wants to remove Debbie
864                bob_central
865                    .transaction
866                    .conversation(&id)
867                    .await
868                    .unwrap()
869                    .remove_members(&[debbie_central.get_client_id().await])
870                    .await
871                    .unwrap();
872                let commit = bob_central.mls_transport().await.latest_commit().await;
873                let proposals = alice_central
874                    .transaction
875                    .conversation(&id)
876                    .await
877                    .unwrap()
878                    .decrypt_message(commit.to_bytes().unwrap())
879                    .await
880                    .unwrap()
881                    .proposals;
882                // Remove is renewed since valid commit removes another
883                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
884                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
885            })
886            .await
887        }
888
889        #[apply(all_cred_cipher)]
890        #[wasm_bindgen_test]
891        pub async fn renews_pending_commit_from_proposal_when_commit_doesnt_remove_same(case: TestContext) {
892            let [mut alice_central, bob_central, charlie_central, debbie_central] = case.sessions().await;
893            Box::pin(async move {
894                let id = conversation_id();
895                alice_central
896                    .transaction
897                    .new_conversation(&id, case.credential_type, case.cfg.clone())
898                    .await
899                    .unwrap();
900                alice_central
901                    .invite_all(&case, &id, [&bob_central, &charlie_central, &debbie_central])
902                    .await
903                    .unwrap();
904
905                // Alice wants to remove Charlie
906                alice_central
907                    .transaction
908                    .new_remove_proposal(&id, charlie_central.get_client_id().await)
909                    .await
910                    .unwrap();
911                alice_central.commit_pending_proposals_unmerged(&id).await;
912
913                // Whereas Bob wants to remove Debbie
914                bob_central
915                    .transaction
916                    .conversation(&id)
917                    .await
918                    .unwrap()
919                    .remove_members(&[debbie_central.get_client_id().await])
920                    .await
921                    .unwrap();
922                let commit = bob_central.mls_transport().await.latest_commit().await;
923                let proposals = alice_central
924                    .transaction
925                    .conversation(&id)
926                    .await
927                    .unwrap()
928                    .decrypt_message(commit.to_bytes().unwrap())
929                    .await
930                    .unwrap()
931                    .proposals;
932                // Remove is renewed since valid commit removes another
933                assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
934                assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
935            })
936            .await
937        }
938    }
939}