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