deltachat/securejoin/
bob.rs

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