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