Skip to main content

core_crypto/mls/conversation/mutable/
wipe.rs

1use core_crypto_keystore::{entities::PersistedMlsGroup, traits::EntityDeleteBorrowed};
2
3use super::Result;
4use crate::{KeystoreError, OpenMlsError, RecursiveError, mls::conversation::ConversationMut};
5
6impl ConversationMut {
7    /// Destroys a group locally
8    ///
9    /// # Errors
10    /// KeyStore errors, such as IO
11    pub async fn wipe(&mut self) -> Result<()> {
12        // to the degree that it's easy, fallibly get things before doing any mutation
13        let mut conversation_cache = self
14            .tx_context
15            .mls_groups()
16            .await
17            .map_err(RecursiveError::context("getting mls conversation cache"))?;
18
19        self.mutate_group(async |transaction, group, _| {
20            // collect all the relevant proposal refs without holding onto the group;
21            // we'll need to mutate the group in shortly
22            let proposals = group
23                .pending_proposals()
24                .map(|proposal| proposal.proposal_reference().to_owned())
25                .collect::<Vec<_>>();
26            for proposal in proposals {
27                // Update proposals rekey the own leaf node. Hence the associated encryption keypair has to be cleared
28                group
29                    .remove_pending_proposal(transaction, &proposal)
30                    .await
31                    .map_err(OpenMlsError::wrap("removing pending proposal"))?;
32            }
33
34            Ok(())
35        })
36        .await?;
37
38        let id = self.id();
39        let context = self
40            .tx_context
41            .inner()
42            .map_err(RecursiveError::context("getting inner context"))?;
43        let tx = context.transaction();
44        PersistedMlsGroup::delete_borrowed(tx, id.keystore()).map_err(KeystoreError::wrap("deleting mls group"))?;
45        let _ = conversation_cache.remove(id);
46
47        // Release the cache guard before clearing the buffers: that path reaches back into the
48        // transaction context, and holding this guard across it would deadlock.
49        drop(conversation_cache);
50
51        Ok(())
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use crate::{mls::conversation::Error, test_utils::*};
58
59    /// Wiping a conversation abandons the messages it had buffered.
60    #[apply(all_cred_cipher)]
61    async fn wipe_abandons_buffered_messages(case: TestContext) {
62        Box::pin(async move {
63            let [mut alice, bob] = case.sessions().await;
64            let conversation = case.create_conversation([&alice, &bob]).await;
65
66            // Bob advances the epoch without Alice hearing about it, then speaks in the new epoch.
67            let conversation = conversation
68                .acting_as(&bob)
69                .await
70                .update()
71                .await
72                .process_member_changes()
73                .await
74                .finish();
75            let app_msg = conversation
76                .guard_of(&bob)
77                .await
78                .encrypt_message(b"Hello Alice !")
79                .await
80                .unwrap();
81
82            // Alice cannot decrypt a message from an epoch she has not reached yet, so she buffers it
83            // until the commit which advances her own epoch arrives.
84            let decrypt = conversation.guard_of(&alice).await.decrypt_message(app_msg).await;
85            assert!(matches!(decrypt.unwrap_err(), Error::BufferedFutureMessage { .. }));
86            assert_eq!(
87                alice.transaction.count_entities().await.pending_messages,
88                1,
89                "the message Alice could not decrypt must have been buffered"
90            );
91
92            // That commit never arrives; Alice wipes the conversation instead.
93            conversation.guard_of(&alice).await.wipe().await.unwrap();
94
95            drop(conversation);
96            alice.commit_transaction().await;
97
98            let counts = alice.transaction.count_entities().await;
99            assert_eq!(counts.group, 0, "the wipe must have removed the conversation");
100            assert_eq!(
101                counts.pending_messages, 0,
102                "the buffered message belongs to a conversation which no longer exists, so wiping that \
103                 conversation must have taken the message with it"
104            );
105        })
106        .await
107    }
108
109    /// Wiping a conversation abandons the commit it had buffered.
110    #[apply(all_cred_cipher)]
111    async fn wipe_abandons_buffered_commits(case: TestContext) {
112        Box::pin(async move {
113            let [mut alice, bob, charlie] = case.sessions().await;
114            let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
115
116            // Bob proposes removing Charlie, but nobody else is told about the proposal.
117            let conversation = conversation
118                .acting_as(&bob)
119                .await
120                .remove_proposal(&charlie)
121                .await
122                .finish();
123
124            // Bob then commits it. The commit refers to the proposal by reference, so Alice — who never
125            // received that proposal — cannot apply the commit, and buffers it to retry once she does.
126            let commit_guard = conversation.acting_as(&bob).await.commit_pending_proposals().await;
127            let (commit_guard, result) = commit_guard.notify_member_fallible(&alice).await;
128            assert!(matches!(result.unwrap_err(), Error::BufferedCommit));
129            let conversation = commit_guard.finish();
130
131            assert_eq!(
132                alice.transaction.count_entities().await.buffered_commits,
133                1,
134                "the commit Alice could not apply must have been buffered"
135            );
136
137            // The proposal never arrives; Alice wipes the conversation instead.
138            conversation.guard_of(&alice).await.wipe().await.unwrap();
139
140            drop(conversation);
141            alice.commit_transaction().await;
142
143            let counts = alice.transaction.count_entities().await;
144            assert_eq!(counts.group, 0, "the wipe must have removed the conversation");
145            assert_eq!(
146                counts.buffered_commits, 0,
147                "the buffered commit belongs to a conversation which no longer exists, so wiping that \
148                 conversation must have taken the commit with it"
149            );
150        })
151        .await
152    }
153}