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::context("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::context("getting inner context"))?;
49 let tx = context.transaction();
50 PersistedMlsGroup::delete_borrowed(tx, id.keystore()).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::context(
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 not have a row
83 /// in `mls_groups` at all yet (it arrived before the conversation's own row was persisted) — so
84 /// keeping buffered messages in step with the conversations they belong to is this layer's job.
85 #[apply(all_cred_cipher)]
86 async fn wipe_abandons_buffered_messages(case: TestContext) {
87 Box::pin(async move {
88 let [mut alice, bob] = case.sessions().await;
89 let conversation = case.create_conversation([&alice, &bob]).await;
90
91 // Bob advances the epoch without Alice hearing about it, then speaks in the new epoch.
92 let conversation = conversation
93 .acting_as(&bob)
94 .await
95 .update()
96 .await
97 .process_member_changes()
98 .await
99 .finish();
100 let app_msg = conversation
101 .guard_of(&bob)
102 .await
103 .encrypt_message(b"Hello Alice !")
104 .await
105 .unwrap();
106
107 // Alice cannot decrypt a message from an epoch she has not reached yet, so she buffers it
108 // until the commit which advances her own epoch arrives.
109 let decrypt = conversation.guard_of(&alice).await.decrypt_message(app_msg).await;
110 assert!(matches!(decrypt.unwrap_err(), Error::BufferedFutureMessage { .. }));
111 assert_eq!(
112 alice.transaction.count_entities().await.pending_messages,
113 1,
114 "the message Alice could not decrypt must have been buffered"
115 );
116
117 // That commit never arrives; Alice wipes the conversation instead.
118 conversation.guard_of(&alice).await.wipe().await.unwrap();
119
120 drop(conversation);
121 alice.commit_transaction().await;
122
123 let counts = alice.transaction.count_entities().await;
124 assert_eq!(counts.group, 0, "the wipe must have removed the conversation");
125 assert_eq!(
126 counts.pending_messages, 0,
127 "the buffered message belongs to a conversation which no longer exists, so wiping that \
128 conversation must have taken the message with it"
129 );
130 })
131 .await
132 }
133
134 /// Wiping a conversation abandons the commit it had buffered.
135 ///
136 /// The same defect as [`wipe_abandons_buffered_messages`], one table over: `mls_buffered_commits` is
137 /// keyed by conversation id and is not cleaned up when the conversation is deleted. It gets its own
138 /// test because the two tables are written and cleared by entirely separate code paths, so covering
139 /// one says nothing about the other.
140 #[apply(all_cred_cipher)]
141 async fn wipe_abandons_buffered_commits(case: TestContext) {
142 Box::pin(async move {
143 let [mut alice, bob, charlie] = case.sessions().await;
144 let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
145
146 // Bob proposes removing Charlie, but nobody else is told about the proposal.
147 let conversation = conversation
148 .acting_as(&bob)
149 .await
150 .remove_proposal(&charlie)
151 .await
152 .finish();
153
154 // Bob then commits it. The commit refers to the proposal by reference, so Alice — who never
155 // received that proposal — cannot apply the commit, and buffers it to retry once she does.
156 let commit_guard = conversation.acting_as(&bob).await.commit_pending_proposals().await;
157 let (commit_guard, result) = commit_guard.notify_member_fallible(&alice).await;
158 assert!(matches!(result.unwrap_err(), Error::BufferedCommit));
159 let conversation = commit_guard.finish();
160
161 assert_eq!(
162 alice.transaction.count_entities().await.buffered_commits,
163 1,
164 "the commit Alice could not apply must have been buffered"
165 );
166
167 // The proposal never arrives; Alice wipes the conversation instead.
168 conversation.guard_of(&alice).await.wipe().await.unwrap();
169
170 drop(conversation);
171 alice.commit_transaction().await;
172
173 let counts = alice.transaction.count_entities().await;
174 assert_eq!(counts.group, 0, "the wipe must have removed the conversation");
175 assert_eq!(
176 counts.buffered_commits, 0,
177 "the buffered commit belongs to a conversation which no longer exists, so wiping that \
178 conversation must have taken the commit with it"
179 );
180 })
181 .await
182 }
183}