deltachat/securejoin/
bob.rs

1//! Bob's side of SecureJoin handling, the joiner-side.
2
3use anyhow::{Context as _, Result};
4
5use super::HandshakeMessage;
6use super::qrinvite::QrInvite;
7use crate::chat::{self, ChatId, is_contact_in_chat};
8use crate::constants::{Blocked, Chattype};
9use crate::contact::Origin;
10use crate::context::Context;
11use crate::events::EventType;
12use crate::key::self_fingerprint;
13use crate::log::info;
14use crate::message::{Message, Viewtype};
15use crate::mimeparser::{MimeMessage, SystemMessage};
16use crate::param::Param;
17use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint};
18use crate::stock_str;
19use crate::sync::Sync::*;
20use crate::tools::{create_smeared_timestamp, time};
21
22/// Starts the securejoin protocol with the QR `invite`.
23///
24/// This will try to start the securejoin protocol for the given QR `invite`.
25///
26/// If Bob already has Alice's key, he sends `AUTH` token
27/// and forgets about the invite.
28/// If Bob does not yet have Alice's key, he sends `vc-request`
29/// or `vg-request` message and stores a row in the `bobstate` table
30/// so he can check Alice's key against the fingerprint
31/// and send `AUTH` token later.
32///
33/// This function takes care of handling multiple concurrent joins and handling errors while
34/// starting the protocol.
35///
36/// # Bob - the joiner's side
37/// ## Step 2 in the "Setup Contact protocol", section 2.1 of countermitm 0.10.0
38///
39/// # Returns
40///
41/// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1
42/// chat with Alice, for a SecureJoin QR this is the group chat.
43pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Result<ChatId> {
44    // A 1:1 chat is needed to send messages to Alice.  When joining a group this chat is
45    // hidden, if a user starts sending messages in it it will be unhidden in
46    // receive_imf.
47    let hidden = match invite {
48        QrInvite::Contact { .. } => Blocked::Not,
49        QrInvite::Group { .. } => Blocked::Yes,
50    };
51    let chat_id = ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
52        .await
53        .with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
54
55    ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
56    context.emit_event(EventType::ContactsChanged(None));
57
58    // Now start the protocol and initialise the state.
59    {
60        let has_key = context
61            .sql
62            .exists(
63                "SELECT COUNT(*) FROM public_keys WHERE fingerprint=?",
64                (invite.fingerprint().hex(),),
65            )
66            .await?;
67
68        // `group_chat_id` is `Some` if group chat
69        // already exists and we are in the chat.
70        let group_chat_id = match invite {
71            QrInvite::Group { ref grpid, .. } => {
72                if let Some((group_chat_id, _blocked)) =
73                    chat::get_chat_id_by_grpid(context, grpid).await?
74                {
75                    if is_contact_in_chat(context, group_chat_id, ContactId::SELF).await? {
76                        Some(group_chat_id)
77                    } else {
78                        None
79                    }
80                } else {
81                    None
82                }
83            }
84            QrInvite::Contact { .. } => None,
85        };
86
87        if let Some(group_chat_id) = group_chat_id {
88            // If QR code is a group invite
89            // and we are already in the chat,
90            // nothing needs to be done.
91            // Even if Alice is not verified, we don't send anything.
92            context.emit_event(EventType::SecurejoinJoinerProgress {
93                contact_id: invite.contact_id(),
94                progress: JoinerProgress::Succeeded.to_usize(),
95            });
96            return Ok(group_chat_id);
97        } else if has_key
98            && verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id())
99                .await?
100        {
101            // The scanned fingerprint matches Alice's key, we can proceed to step 4b.
102            info!(context, "Taking securejoin protocol shortcut");
103            send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth)
104                .await?;
105
106            context.emit_event(EventType::SecurejoinJoinerProgress {
107                contact_id: invite.contact_id(),
108                progress: JoinerProgress::RequestWithAuthSent.to_usize(),
109            });
110        } else {
111            send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::Request).await?;
112
113            insert_new_db_entry(context, invite.clone(), chat_id).await?;
114        }
115    }
116
117    match invite {
118        QrInvite::Group { .. } => {
119            // For a secure-join we need to create the group and add the contact.  The group will
120            // only become usable once the protocol is finished.
121            let group_chat_id = joining_chat_id(context, &invite, chat_id).await?;
122            if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? {
123                chat::add_to_chat_contacts_table(
124                    context,
125                    time(),
126                    group_chat_id,
127                    &[invite.contact_id()],
128                )
129                .await?;
130            }
131            let msg = stock_str::secure_join_started(context, invite.contact_id()).await;
132            chat::add_info_msg(context, group_chat_id, &msg, time()).await?;
133            Ok(group_chat_id)
134        }
135        QrInvite::Contact { .. } => {
136            // For setup-contact the BobState already ensured the 1:1 chat exists because it
137            // uses it to send the handshake messages.
138            // Calculate the sort timestamp before checking the chat protection status so that if we
139            // race with its change, we don't add our message below the protection message.
140            let sort_to_bottom = true;
141            let (received, incoming) = (false, false);
142            let ts_sort = chat_id
143                .calc_sort_timestamp(context, 0, sort_to_bottom, received, incoming)
144                .await?;
145            let ts_start = time();
146            chat::add_info_msg_with_cmd(
147                context,
148                chat_id,
149                &stock_str::securejoin_wait(context).await,
150                SystemMessage::SecurejoinWait,
151                ts_sort,
152                Some(ts_start),
153                None,
154                None,
155                None,
156            )
157            .await?;
158            Ok(chat_id)
159        }
160    }
161}
162
163/// Inserts a new entry in the bobstate table.
164///
165/// Returns the ID of the newly inserted entry.
166async fn insert_new_db_entry(context: &Context, invite: QrInvite, chat_id: ChatId) -> Result<i64> {
167    context
168        .sql
169        .insert(
170            "INSERT INTO bobstate (invite, next_step, chat_id) VALUES (?, ?, ?);",
171            (invite, 0, chat_id),
172        )
173        .await
174}
175
176/// Handles `vc-auth-required` and `vg-auth-required` handshake messages.
177///
178/// # Bob - the joiner's side
179/// ## Step 4 in the "Setup Contact protocol"
180pub(super) async fn handle_auth_required(
181    context: &Context,
182    message: &MimeMessage,
183) -> Result<HandshakeMessage> {
184    // Load all Bob states that expect `vc-auth-required` or `vg-auth-required`.
185    let bob_states = context
186        .sql
187        .query_map_vec("SELECT id, invite, chat_id FROM bobstate", (), |row| {
188            let row_id: i64 = row.get(0)?;
189            let invite: QrInvite = row.get(1)?;
190            let chat_id: ChatId = row.get(2)?;
191            Ok((row_id, invite, chat_id))
192        })
193        .await?;
194
195    info!(
196        context,
197        "Bob Step 4 - handling {{vc,vg}}-auth-required message."
198    );
199
200    let mut auth_sent = false;
201    for (bobstate_row_id, invite, chat_id) in bob_states {
202        if !encrypted_and_signed(context, message, invite.fingerprint()) {
203            continue;
204        }
205
206        if !verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await?
207        {
208            continue;
209        }
210
211        info!(context, "Fingerprint verified.",);
212        send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth).await?;
213        context
214            .sql
215            .execute("DELETE FROM bobstate WHERE id=?", (bobstate_row_id,))
216            .await?;
217
218        match invite {
219            QrInvite::Contact { .. } => {}
220            QrInvite::Group { .. } => {
221                // The message reads "Alice replied, waiting to be added to the group…",
222                // so only show it on secure-join and not on setup-contact.
223                let contact_id = invite.contact_id();
224                let msg = stock_str::secure_join_replies(context, contact_id).await;
225                let chat_id = joining_chat_id(context, &invite, chat_id).await?;
226                chat::add_info_msg(context, chat_id, &msg, time()).await?;
227            }
228        }
229
230        context.emit_event(EventType::SecurejoinJoinerProgress {
231            contact_id: invite.contact_id(),
232            progress: JoinerProgress::RequestWithAuthSent.to_usize(),
233        });
234
235        auth_sent = true;
236    }
237
238    if auth_sent {
239        // Delete the message from IMAP server.
240        Ok(HandshakeMessage::Done)
241    } else {
242        // We have not found any corresponding AUTH codes,
243        // maybe another Bob device has scanned the QR code.
244        // Leave the message on IMAP server and let the other device
245        // process it.
246        Ok(HandshakeMessage::Ignore)
247    }
248}
249
250/// Sends the requested handshake message to Alice.
251pub(crate) async fn send_handshake_message(
252    context: &Context,
253    invite: &QrInvite,
254    chat_id: ChatId,
255    step: BobHandshakeMsg,
256) -> Result<()> {
257    let mut msg = Message {
258        viewtype: Viewtype::Text,
259        text: step.body_text(invite),
260        hidden: true,
261        ..Default::default()
262    };
263    msg.param.set_cmd(SystemMessage::SecurejoinMessage);
264
265    // Sends the step in Secure-Join header.
266    msg.param.set(Param::Arg, step.securejoin_header(invite));
267
268    match step {
269        BobHandshakeMsg::Request => {
270            // Sends the Secure-Join-Invitenumber header in mimefactory.rs.
271            msg.param.set(Param::Arg2, invite.invitenumber());
272            msg.force_plaintext();
273        }
274        BobHandshakeMsg::RequestWithAuth => {
275            // Sends the Secure-Join-Auth header in mimefactory.rs.
276            msg.param.set(Param::Arg2, invite.authcode());
277            msg.param.set_int(Param::GuaranteeE2ee, 1);
278
279            // Sends our own fingerprint in the Secure-Join-Fingerprint header.
280            let bob_fp = self_fingerprint(context).await?;
281            msg.param.set(Param::Arg3, bob_fp);
282
283            // Sends the grpid in the Secure-Join-Group header.
284            //
285            // `Secure-Join-Group` header is deprecated,
286            // but old Delta Chat core requires that Alice receives it.
287            //
288            // Previous Delta Chat core also sent `Secure-Join-Group` header
289            // in `vg-request` messages,
290            // but it was not used on the receiver.
291            if let QrInvite::Group { grpid, .. } = invite {
292                msg.param.set(Param::Arg4, grpid);
293            }
294        }
295    };
296
297    chat::send_msg(context, chat_id, &mut msg).await?;
298    Ok(())
299}
300
301/// Identifies the SecureJoin handshake messages Bob can send.
302pub(crate) enum BobHandshakeMsg {
303    /// vc-request or vg-request
304    Request,
305    /// vc-request-with-auth or vg-request-with-auth
306    RequestWithAuth,
307}
308
309impl BobHandshakeMsg {
310    /// Returns the text to send in the body of the handshake message.
311    ///
312    /// This text has no significance to the protocol, but would be visible if users see
313    /// this email message directly, e.g. when accessing their email without using
314    /// DeltaChat.
315    fn body_text(&self, invite: &QrInvite) -> String {
316        format!("Secure-Join: {}", self.securejoin_header(invite))
317    }
318
319    /// Returns the `Secure-Join` header value.
320    ///
321    /// This identifies the step this message is sending information about.  Most protocol
322    /// steps include additional information into other headers, see
323    /// [`send_handshake_message`] for these.
324    fn securejoin_header(&self, invite: &QrInvite) -> &'static str {
325        match self {
326            Self::Request => match invite {
327                QrInvite::Contact { .. } => "vc-request",
328                QrInvite::Group { .. } => "vg-request",
329            },
330            Self::RequestWithAuth => match invite {
331                QrInvite::Contact { .. } => "vc-request-with-auth",
332                QrInvite::Group { .. } => "vg-request-with-auth",
333            },
334        }
335    }
336}
337
338/// Returns the [`ChatId`] of the chat being joined.
339///
340/// This is the chat in which you want to notify the user as well.
341///
342/// When joining a group this is the [`ChatId`] of the group chat, when verifying a
343/// contact this is the [`ChatId`] of the 1:1 chat.
344/// The group chat will be created if it does not yet exist.
345async fn joining_chat_id(
346    context: &Context,
347    invite: &QrInvite,
348    alice_chat_id: ChatId,
349) -> Result<ChatId> {
350    match invite {
351        QrInvite::Contact { .. } => Ok(alice_chat_id),
352        QrInvite::Group { grpid, name, .. } => {
353            let group_chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? {
354                Some((chat_id, _blocked)) => {
355                    chat_id.unblock_ex(context, Nosync).await?;
356                    chat_id
357                }
358                None => {
359                    ChatId::create_multiuser_record(
360                        context,
361                        Chattype::Group,
362                        grpid,
363                        name,
364                        Blocked::Not,
365                        None,
366                        create_smeared_timestamp(context),
367                    )
368                    .await?
369                }
370            };
371            Ok(group_chat_id)
372        }
373    }
374}
375
376/// Progress updates for [`EventType::SecurejoinJoinerProgress`].
377///
378/// This has an `From<JoinerProgress> for usize` impl yielding numbers between 0 and a 1000
379/// which can be shown as a progress bar.
380pub(crate) enum JoinerProgress {
381    /// vg-vc-request-with-auth sent.
382    ///
383    /// Typically shows as "alice@addr verified, introducing myself."
384    RequestWithAuthSent,
385    /// Completed securejoin.
386    Succeeded,
387}
388
389impl JoinerProgress {
390    #[expect(clippy::wrong_self_convention)]
391    pub(crate) fn to_usize(self) -> usize {
392        match self {
393            JoinerProgress::RequestWithAuthSent => 400,
394            JoinerProgress::Succeeded => 1000,
395        }
396    }
397}