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 provider = self.crypto_provider().await?;
14        let mut conversation_cache = self
15            .tx_context
16            .mls_groups()
17            .await
18            .map_err(RecursiveError::transaction("getting mls conversation cache"))?;
19
20        self.mutate_group(async |transaction, group, _| {
21            // the own client may or may not have generated an epoch keypair in the previous epoch
22            // Since it is a terminal operation, ignoring the error is fine here.
23            let _ = group.delete_previous_epoch_keypairs(&provider).await;
24
25            // collect all the relevant proposal refs without holding onto the group;
26            // we'll need to mutate the group in shortly
27            let proposals = group
28                .pending_proposals()
29                .map(|proposal| proposal.proposal_reference().to_owned())
30                .collect::<Vec<_>>();
31            for proposal in proposals {
32                // Update proposals rekey the own leaf node. Hence the associated encryption keypair has to be cleared
33                group
34                    .remove_pending_proposal(transaction, &proposal)
35                    .await
36                    .map_err(OpenMlsError::wrap("removing pending proposal"))?;
37            }
38
39            Ok(())
40        })
41        .await?;
42
43        let id = self.id();
44        let context = self
45            .tx_context
46            .inner()
47            .await
48            .map_err(RecursiveError::transaction("getting inner context"))?;
49        let tx = context.transaction();
50        PersistedMlsGroup::delete_borrowed(tx, id.as_ref()).map_err(KeystoreError::wrap("deleting mls group"))?;
51        let _ = conversation_cache.remove(id);
52
53        // Release the cache guard before clearing the buffers: that path reaches back into the
54        // transaction context, and holding this guard across it would deadlock.
55        drop(conversation_cache);
56
57        // Any message or commit this conversation had buffered is unreachable now that the
58        // conversation is gone, so it has to go with it.
59        self.tx_context
60            .clear_orphaned_conversation_buffers(id)
61            .await
62            .map_err(RecursiveError::transaction(
63                "clearing buffered messages and commits of a wiped conversation",
64            ))?;
65
66        Ok(())
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use crate::{mls::conversation::Error, test_utils::*};
73
74    /// Wiping a conversation abandons the messages it had buffered.
75    ///
76    /// [`ConversationMut::wipe`] deletes the group and nothing else, so a message buffered for a future
77    /// epoch outlives the conversation it was buffered for. That row is then unreachable: every route to
78    /// a buffered message runs through a conversation, and this conversation no longer exists. It can
79    /// never be restored, and nothing will ever delete it, so the keystore carries it forever.
80    ///
81    /// Nothing in the schema prevents this. `mls_pending_messages.conversation_id` has no foreign key —
82    /// V31 dropped the one it used to have, because a buffered message's conversation may live in either
83    /// `mls_groups` or `mls_pending_groups` — so keeping the two in step is this layer's job.
84    #[apply(all_cred_cipher)]
85    async fn wipe_abandons_buffered_messages(case: TestContext) {
86        Box::pin(async move {
87            let [mut alice, bob] = case.sessions().await;
88            let conversation = case.create_conversation([&alice, &bob]).await;
89
90            // Bob advances the epoch without Alice hearing about it, then speaks in the new epoch.
91            let conversation = conversation
92                .acting_as(&bob)
93                .await
94                .update()
95                .await
96                .process_member_changes()
97                .await
98                .finish();
99            let app_msg = conversation
100                .guard_of(&bob)
101                .await
102                .encrypt_message(b"Hello Alice !")
103                .await
104                .unwrap();
105
106            // Alice cannot decrypt a message from an epoch she has not reached yet, so she buffers it
107            // until the commit which advances her own epoch arrives.
108            let decrypt = conversation.guard_of(&alice).await.decrypt_message(app_msg).await;
109            assert!(matches!(decrypt.unwrap_err(), Error::BufferedFutureMessage { .. }));
110            assert_eq!(
111                alice.transaction.count_entities().await.pending_messages,
112                1,
113                "the message Alice could not decrypt must have been buffered"
114            );
115
116            // That commit never arrives; Alice wipes the conversation instead.
117            conversation.guard_of(&alice).await.wipe().await.unwrap();
118
119            drop(conversation);
120            alice.commit_transaction().await;
121
122            let counts = alice.transaction.count_entities().await;
123            assert_eq!(counts.group, 0, "the wipe must have removed the conversation");
124            assert_eq!(
125                counts.pending_messages, 0,
126                "the buffered message belongs to a conversation which no longer exists, so wiping that \
127                 conversation must have taken the message with it"
128            );
129        })
130        .await
131    }
132
133    /// Wiping a conversation abandons the commit it had buffered.
134    ///
135    /// The same defect as [`wipe_abandons_buffered_messages`], one table over: `mls_buffered_commits` is
136    /// keyed by conversation id and is not cleaned up when the conversation is deleted. It gets its own
137    /// test because the two tables are written and cleared by entirely separate code paths, so covering
138    /// one says nothing about the other.
139    #[apply(all_cred_cipher)]
140    async fn wipe_abandons_buffered_commits(case: TestContext) {
141        Box::pin(async move {
142            let [mut alice, bob, charlie] = case.sessions().await;
143            let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
144
145            // Bob proposes removing Charlie, but nobody else is told about the proposal.
146            let conversation = conversation
147                .acting_as(&bob)
148                .await
149                .remove_proposal(&charlie)
150                .await
151                .finish();
152
153            // Bob then commits it. The commit refers to the proposal by reference, so Alice — who never
154            // received that proposal — cannot apply the commit, and buffers it to retry once she does.
155            let commit_guard = conversation.acting_as(&bob).await.commit_pending_proposals().await;
156            let (commit_guard, result) = commit_guard.notify_member_fallible(&alice).await;
157            assert!(matches!(result.unwrap_err(), Error::BufferedCommit));
158            let conversation = commit_guard.finish();
159
160            assert_eq!(
161                alice.transaction.count_entities().await.buffered_commits,
162                1,
163                "the commit Alice could not apply must have been buffered"
164            );
165
166            // The proposal never arrives; Alice wipes the conversation instead.
167            conversation.guard_of(&alice).await.wipe().await.unwrap();
168
169            drop(conversation);
170            alice.commit_transaction().await;
171
172            let counts = alice.transaction.count_entities().await;
173            assert_eq!(counts.group, 0, "the wipe must have removed the conversation");
174            assert_eq!(
175                counts.buffered_commits, 0,
176                "the buffered commit belongs to a conversation which no longer exists, so wiping that \
177                 conversation must have taken the commit with it"
178            );
179        })
180        .await
181    }
182}