deltachat/
securejoin.rs

1//! Implementation of [SecureJoin protocols](https://securejoin.delta.chat/).
2
3use anyhow::{Context as _, Error, Result, bail, ensure};
4use deltachat_contact_tools::ContactAddress;
5use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
6
7use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid};
8use crate::chatlist_events;
9use crate::config::Config;
10use crate::constants::{Blocked, Chattype, NON_ALPHANUMERIC_WITHOUT_DOT};
11use crate::contact::mark_contact_id_as_verified;
12use crate::contact::{Contact, ContactId, Origin};
13use crate::context::Context;
14use crate::e2ee::ensure_secret_key_exists;
15use crate::events::EventType;
16use crate::headerdef::HeaderDef;
17use crate::key::{DcKey, Fingerprint, load_self_public_key};
18use crate::log::{error, info, warn};
19use crate::logged_debug_assert;
20use crate::message::{Message, Viewtype};
21use crate::mimeparser::{MimeMessage, SystemMessage};
22use crate::param::Param;
23use crate::qr::check_qr;
24use crate::securejoin::bob::JoinerProgress;
25use crate::sync::Sync::*;
26use crate::token;
27
28mod bob;
29mod qrinvite;
30
31use qrinvite::QrInvite;
32
33use crate::token::Namespace;
34
35fn inviter_progress(
36    context: &Context,
37    contact_id: ContactId,
38    step: &str,
39    progress: usize,
40) -> Result<()> {
41    logged_debug_assert!(
42        context,
43        progress <= 1000,
44        "inviter_progress: contact {contact_id}, progress={progress}, but value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success."
45    );
46    let chat_type = match step.get(..3) {
47        Some("vc-") => Chattype::Single,
48        Some("vg-") => Chattype::Group,
49        _ => bail!("Unknown securejoin step {step}"),
50    };
51    context.emit_event(EventType::SecurejoinInviterProgress {
52        contact_id,
53        chat_type,
54        progress,
55    });
56
57    Ok(())
58}
59
60/// Generates a Secure Join QR code.
61///
62/// With `group` set to `None` this generates a setup-contact QR code, with `group` set to a
63/// [`ChatId`] generates a join-group QR code for the given chat.
64pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Result<String> {
65    /*=======================================================
66    ====             Alice - the inviter side            ====
67    ====   Step 1 in "Setup verified contact" protocol   ====
68    =======================================================*/
69
70    ensure_secret_key_exists(context).await.ok();
71
72    let chat = match group {
73        Some(id) => {
74            let chat = Chat::load_from_db(context, id).await?;
75            ensure!(
76                chat.typ == Chattype::Group,
77                "Can't generate SecureJoin QR code for 1:1 chat {id}"
78            );
79            if chat.grpid.is_empty() {
80                let err = format!("Can't generate QR code, chat {id} is a email thread");
81                error!(context, "get_securejoin_qr: {}.", err);
82                bail!(err);
83            }
84            Some(chat)
85        }
86        None => None,
87    };
88    let grpid = chat.as_ref().map(|c| c.grpid.as_str());
89    let sync_token = token::lookup(context, Namespace::InviteNumber, grpid)
90        .await?
91        .is_none();
92    // invitenumber will be used to allow starting the handshake,
93    // auth will be used to verify the fingerprint
94    let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, grpid).await?;
95    let auth = token::lookup_or_new(context, Namespace::Auth, grpid).await?;
96    let self_addr = context.get_primary_self_addr().await?;
97    let self_name = context
98        .get_config(Config::Displayname)
99        .await?
100        .unwrap_or_default();
101
102    let fingerprint = get_self_fingerprint(context).await?;
103
104    let self_addr_urlencoded =
105        utf8_percent_encode(&self_addr, NON_ALPHANUMERIC_WITHOUT_DOT).to_string();
106    let self_name_urlencoded =
107        utf8_percent_encode(&self_name, NON_ALPHANUMERIC_WITHOUT_DOT).to_string();
108
109    let qr = if let Some(chat) = chat {
110        // parameters used: a=g=x=i=s=
111        let group_name = chat.get_name();
112        let group_name_urlencoded = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string();
113        if sync_token {
114            context
115                .sync_qr_code_tokens(Some(chat.grpid.as_str()))
116                .await?;
117            context.scheduler.interrupt_inbox().await;
118        }
119        format!(
120            "https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}",
121            fingerprint.hex(),
122            self_addr_urlencoded,
123            &group_name_urlencoded,
124            &chat.grpid,
125            &invitenumber,
126            &auth,
127        )
128    } else {
129        // parameters used: a=n=i=s=
130        if sync_token {
131            context.sync_qr_code_tokens(None).await?;
132            context.scheduler.interrupt_inbox().await;
133        }
134        format!(
135            "https://i.delta.chat/#{}&a={}&n={}&i={}&s={}",
136            fingerprint.hex(),
137            self_addr_urlencoded,
138            self_name_urlencoded,
139            &invitenumber,
140            &auth,
141        )
142    };
143
144    info!(context, "Generated QR code.");
145    Ok(qr)
146}
147
148async fn get_self_fingerprint(context: &Context) -> Result<Fingerprint> {
149    let key = load_self_public_key(context)
150        .await
151        .context("Failed to load key")?;
152    Ok(key.dc_fingerprint())
153}
154
155/// Take a scanned QR-code and do the setup-contact/join-group/invite handshake.
156///
157/// This is the start of the process for the joiner.  See the module and ffi documentation
158/// for more details.
159///
160/// The function returns immediately and the handshake will run in background.
161pub async fn join_securejoin(context: &Context, qr: &str) -> Result<ChatId> {
162    securejoin(context, qr).await.map_err(|err| {
163        warn!(context, "Fatal joiner error: {:#}", err);
164        // The user just scanned this QR code so has context on what failed.
165        error!(context, "QR process failed");
166        err
167    })
168}
169
170async fn securejoin(context: &Context, qr: &str) -> Result<ChatId> {
171    /*========================================================
172    ====             Bob - the joiner's side             =====
173    ====   Step 2 in "Setup verified contact" protocol   =====
174    ========================================================*/
175
176    info!(context, "Requesting secure-join ...",);
177    let qr_scan = check_qr(context, qr).await?;
178
179    let invite = QrInvite::try_from(qr_scan)?;
180
181    bob::start_protocol(context, invite).await
182}
183
184/// Send handshake message from Alice's device.
185async fn send_alice_handshake_msg(
186    context: &Context,
187    contact_id: ContactId,
188    step: &str,
189) -> Result<()> {
190    let mut msg = Message {
191        viewtype: Viewtype::Text,
192        text: format!("Secure-Join: {step}"),
193        hidden: true,
194        ..Default::default()
195    };
196    msg.param.set_cmd(SystemMessage::SecurejoinMessage);
197    msg.param.set(Param::Arg, step);
198    msg.param.set_int(Param::GuaranteeE2ee, 1);
199    chat::send_msg(
200        context,
201        ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Yes)
202            .await?
203            .id,
204        &mut msg,
205    )
206    .await?;
207    Ok(())
208}
209
210/// Get an unblocked chat that can be used for info messages.
211async fn info_chat_id(context: &Context, contact_id: ContactId) -> Result<ChatId> {
212    let chat_id_blocked = ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Not).await?;
213    Ok(chat_id_blocked.id)
214}
215
216/// Checks fingerprint and marks the contact as verified
217/// if fingerprint matches.
218async fn verify_sender_by_fingerprint(
219    context: &Context,
220    fingerprint: &Fingerprint,
221    contact_id: ContactId,
222) -> Result<bool> {
223    let contact = Contact::get_by_id(context, contact_id).await?;
224    let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint);
225    if is_verified {
226        mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
227    }
228    Ok(is_verified)
229}
230
231/// What to do with a Secure-Join handshake message after it was handled.
232///
233/// This status is returned to [`receive_imf_inner`] which will use it to decide what to do
234/// next with this incoming setup-contact/secure-join handshake message.
235///
236/// [`receive_imf_inner`]: crate::receive_imf::receive_imf_inner
237#[derive(Debug, PartialEq, Eq)]
238pub(crate) enum HandshakeMessage {
239    /// The message has been fully handled and should be removed/delete.
240    ///
241    /// This removes the message both locally and on the IMAP server.
242    Done,
243    /// The message should be ignored/hidden, but not removed/deleted.
244    ///
245    /// This leaves it on the IMAP server.  It means other devices on this account can
246    /// receive and potentially process this message as well.  This is useful for example
247    /// when the other device is running the protocol and has the relevant QR-code
248    /// information while this device does not have the joiner state.
249    Ignore,
250    /// The message should be further processed by incoming message handling.
251    ///
252    /// This may for example result in a group being created if it is a message which added
253    /// us to a group (a `vg-member-added` message).
254    Propagate,
255}
256
257/// Handle incoming secure-join handshake.
258///
259/// This function will update the securejoin state in the database as the protocol
260/// progresses.
261///
262/// A message which results in [`Err`] will be hidden from the user but not deleted, it may
263/// be a valid message for something else we are not aware off.  E.g. it could be part of a
264/// handshake performed by another DC app on the same account.
265///
266/// When `handle_securejoin_handshake()` is called, the message is not yet filed in the
267/// database; this is done by `receive_imf()` later on as needed.
268pub(crate) async fn handle_securejoin_handshake(
269    context: &Context,
270    mime_message: &mut MimeMessage,
271    contact_id: ContactId,
272) -> Result<HandshakeMessage> {
273    if contact_id.is_special() {
274        return Err(Error::msg("Can not be called with special contact ID"));
275    }
276    let step = mime_message
277        .get_header(HeaderDef::SecureJoin)
278        .context("Not a Secure-Join message")?;
279
280    info!(context, "Received secure-join message {step:?}.");
281
282    let join_vg = step.starts_with("vg-");
283
284    if !matches!(step, "vg-request" | "vc-request") {
285        let mut self_found = false;
286        let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
287        for (addr, key) in &mime_message.gossiped_keys {
288            if key.public_key.dc_fingerprint() == self_fingerprint
289                && context.is_self_addr(addr).await?
290            {
291                self_found = true;
292                break;
293            }
294        }
295        if !self_found {
296            // This message isn't intended for us. Possibly the peer doesn't own the key which the
297            // message is signed with but forwarded someone's message to us.
298            warn!(context, "Step {step}: No self addr+pubkey gossip found.");
299            return Ok(HandshakeMessage::Ignore);
300        }
301    }
302
303    match step {
304        "vg-request" | "vc-request" => {
305            /*=======================================================
306            ====             Alice - the inviter side            ====
307            ====   Step 3 in "Setup verified contact" protocol   ====
308            =======================================================*/
309
310            // this message may be unencrypted (Bob, the joiner and the sender, might not have Alice's key yet)
311            // it just ensures, we have Bobs key now. If we do _not_ have the key because eg. MitM has removed it,
312            // send_message() will fail with the error "End-to-end-encryption unavailable unexpectedly.", so, there is no additional check needed here.
313            // verify that the `Secure-Join-Invitenumber:`-header matches invitenumber written to the QR code
314            let invitenumber = match mime_message.get_header(HeaderDef::SecureJoinInvitenumber) {
315                Some(n) => n,
316                None => {
317                    warn!(context, "Secure-join denied (invitenumber missing)");
318                    return Ok(HandshakeMessage::Ignore);
319                }
320            };
321            if !token::exists(context, token::Namespace::InviteNumber, invitenumber).await? {
322                warn!(context, "Secure-join denied (bad invitenumber).");
323                return Ok(HandshakeMessage::Ignore);
324            }
325
326            inviter_progress(context, contact_id, step, 300)?;
327
328            let from_addr = ContactAddress::new(&mime_message.from.addr)?;
329            let autocrypt_fingerprint = mime_message.autocrypt_fingerprint.as_deref().unwrap_or("");
330            let (autocrypt_contact_id, _) = Contact::add_or_lookup_ex(
331                context,
332                "",
333                &from_addr,
334                autocrypt_fingerprint,
335                Origin::IncomingUnknownFrom,
336            )
337            .await?;
338
339            // Alice -> Bob
340            send_alice_handshake_msg(
341                context,
342                autocrypt_contact_id,
343                &format!("{}-auth-required", &step.get(..2).unwrap_or_default()),
344            )
345            .await
346            .context("failed sending auth-required handshake message")?;
347            Ok(HandshakeMessage::Done)
348        }
349        "vg-auth-required" | "vc-auth-required" => {
350            /*========================================================
351            ====             Bob - the joiner's side             =====
352            ====   Step 4 in "Setup verified contact" protocol   =====
353            ========================================================*/
354            bob::handle_auth_required(context, mime_message).await
355        }
356        "vg-request-with-auth" | "vc-request-with-auth" => {
357            /*==========================================================
358            ====              Alice - the inviter side              ====
359            ====   Steps 5+6 in "Setup verified contact" protocol   ====
360            ====  Step 6 in "Out-of-band verified groups" protocol  ====
361            ==========================================================*/
362
363            // verify that Secure-Join-Fingerprint:-header matches the fingerprint of Bob
364            let Some(fp) = mime_message.get_header(HeaderDef::SecureJoinFingerprint) else {
365                warn!(
366                    context,
367                    "Ignoring {step} message because fingerprint is not provided."
368                );
369                return Ok(HandshakeMessage::Ignore);
370            };
371            let fingerprint: Fingerprint = fp.parse()?;
372            if !encrypted_and_signed(context, mime_message, &fingerprint) {
373                warn!(
374                    context,
375                    "Ignoring {step} message because the message is not encrypted."
376                );
377                return Ok(HandshakeMessage::Ignore);
378            }
379            // verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code
380            let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
381                warn!(
382                    context,
383                    "Ignoring {step} message because of missing auth code."
384                );
385                return Ok(HandshakeMessage::Ignore);
386            };
387            let Some(grpid) = token::auth_foreign_key(context, auth).await? else {
388                warn!(
389                    context,
390                    "Ignoring {step} message because of invalid auth code."
391                );
392                return Ok(HandshakeMessage::Ignore);
393            };
394            let group_chat_id = match grpid.as_str() {
395                "" => None,
396                id => {
397                    let Some((chat_id, ..)) = get_chat_id_by_grpid(context, id).await? else {
398                        warn!(context, "Ignoring {step} message: unknown grpid {id}.",);
399                        return Ok(HandshakeMessage::Ignore);
400                    };
401                    Some(chat_id)
402                }
403            };
404
405            if !verify_sender_by_fingerprint(context, &fingerprint, contact_id).await? {
406                warn!(
407                    context,
408                    "Ignoring {step} message because of fingerprint mismatch."
409                );
410                return Ok(HandshakeMessage::Ignore);
411            }
412            info!(context, "Fingerprint verified via Auth code.",);
413            contact_id.regossip_keys(context).await?;
414            ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
415            // for setup-contact, make Alice's one-to-one chat with Bob visible
416            // (secure-join-information are shown in the group chat)
417            if !join_vg {
418                ChatId::create_for_contact(context, contact_id).await?;
419            }
420            context.emit_event(EventType::ContactsChanged(Some(contact_id)));
421            inviter_progress(context, contact_id, step, 600)?;
422            if let Some(group_chat_id) = group_chat_id {
423                // Join group.
424                secure_connection_established(
425                    context,
426                    contact_id,
427                    group_chat_id,
428                    mime_message.timestamp_sent,
429                )
430                .await?;
431                chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true)
432                    .await?;
433                inviter_progress(context, contact_id, step, 800)?;
434                inviter_progress(context, contact_id, step, 1000)?;
435                // IMAP-delete the message to avoid handling it by another device and adding the
436                // member twice. Another device will know the member's key from Autocrypt-Gossip.
437                Ok(HandshakeMessage::Done)
438            } else {
439                // Setup verified contact.
440                secure_connection_established(
441                    context,
442                    contact_id,
443                    info_chat_id(context, contact_id).await?,
444                    mime_message.timestamp_sent,
445                )
446                .await?;
447                send_alice_handshake_msg(context, contact_id, "vc-contact-confirm")
448                    .await
449                    .context("failed sending vc-contact-confirm message")?;
450
451                inviter_progress(context, contact_id, step, 1000)?;
452                Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
453            }
454        }
455        /*=======================================================
456        ====             Bob - the joiner's side             ====
457        ====   Step 7 in "Setup verified contact" protocol   ====
458        =======================================================*/
459        "vc-contact-confirm" => {
460            context.emit_event(EventType::SecurejoinJoinerProgress {
461                contact_id,
462                progress: JoinerProgress::Succeeded.to_usize(),
463            });
464            Ok(HandshakeMessage::Ignore)
465        }
466        "vg-member-added" => {
467            let Some(member_added) = mime_message.get_header(HeaderDef::ChatGroupMemberAdded)
468            else {
469                warn!(
470                    context,
471                    "vg-member-added without Chat-Group-Member-Added header."
472                );
473                return Ok(HandshakeMessage::Propagate);
474            };
475            if !context.is_self_addr(member_added).await? {
476                info!(
477                    context,
478                    "Member {member_added} added by unrelated SecureJoin process."
479                );
480                return Ok(HandshakeMessage::Propagate);
481            }
482
483            context.emit_event(EventType::SecurejoinJoinerProgress {
484                contact_id,
485                progress: JoinerProgress::Succeeded.to_usize(),
486            });
487            Ok(HandshakeMessage::Propagate)
488        }
489
490        "vg-member-added-received" | "vc-contact-confirm-received" => {
491            // Deprecated steps, delete them immediately.
492            Ok(HandshakeMessage::Done)
493        }
494        _ => {
495            warn!(context, "invalid step: {}", step);
496            Ok(HandshakeMessage::Ignore)
497        }
498    }
499}
500
501/// Observe self-sent Securejoin message.
502///
503/// In a multi-device-setup, there may be other devices that "see" the handshake messages.
504/// If we see self-sent messages encrypted+signed correctly with our key,
505/// we can make some conclusions of it.
506///
507/// If we see self-sent {vc,vg}-request-with-auth,
508/// we know that we are Bob (joiner-observer)
509/// that just marked peer (Alice) as verified
510/// either after receiving {vc,vg}-auth-required
511/// or immediately after scanning the QR-code
512/// if the key was already known.
513///
514/// If we see self-sent vc-contact-confirm or vg-member-added message,
515/// we know that we are Alice (inviter-observer)
516/// that just marked peer (Bob) as verified
517/// in response to correct vc-request-with-auth message.
518pub(crate) async fn observe_securejoin_on_other_device(
519    context: &Context,
520    mime_message: &MimeMessage,
521    contact_id: ContactId,
522) -> Result<HandshakeMessage> {
523    if contact_id.is_special() {
524        return Err(Error::msg("Can not be called with special contact ID"));
525    }
526    let step = mime_message
527        .get_header(HeaderDef::SecureJoin)
528        .context("Not a Secure-Join message")?;
529    info!(context, "Observing secure-join message {step:?}.");
530
531    if !matches!(
532        step,
533        "vg-request-with-auth" | "vc-request-with-auth" | "vg-member-added" | "vc-contact-confirm"
534    ) {
535        return Ok(HandshakeMessage::Ignore);
536    };
537
538    if !encrypted_and_signed(context, mime_message, &get_self_fingerprint(context).await?) {
539        warn!(
540            context,
541            "Observed SecureJoin message is not encrypted correctly."
542        );
543        return Ok(HandshakeMessage::Ignore);
544    }
545
546    let contact = Contact::get_by_id(context, contact_id).await?;
547    let addr = contact.get_addr().to_lowercase();
548
549    let Some(key) = mime_message.gossiped_keys.get(&addr) else {
550        warn!(context, "No gossip header for {addr} at step {step}.");
551        return Ok(HandshakeMessage::Ignore);
552    };
553
554    let Some(contact_fingerprint) = contact.fingerprint() else {
555        // Not a key-contact, should not happen.
556        warn!(context, "Contact does not have a fingerprint.");
557        return Ok(HandshakeMessage::Ignore);
558    };
559
560    if key.public_key.dc_fingerprint() != contact_fingerprint {
561        // Fingerprint does not match, ignore.
562        warn!(context, "Fingerprint does not match.");
563        return Ok(HandshakeMessage::Ignore);
564    }
565
566    mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
567
568    ChatId::set_protection_for_contact(context, contact_id, mime_message.timestamp_sent).await?;
569
570    if step == "vg-member-added" {
571        inviter_progress(context, contact_id, step, 800)?;
572    }
573    if step == "vg-member-added" || step == "vc-contact-confirm" {
574        inviter_progress(context, contact_id, step, 1000)?;
575    }
576
577    if step == "vg-request-with-auth" || step == "vc-request-with-auth" {
578        // This actually reflects what happens on the first device (which does the secure
579        // join) and causes a subsequent "vg-member-added" message to create an unblocked
580        // verified group.
581        ChatId::create_for_contact_with_blocked(context, contact_id, Blocked::Not).await?;
582    }
583
584    if step == "vg-member-added" {
585        Ok(HandshakeMessage::Propagate)
586    } else {
587        Ok(HandshakeMessage::Ignore)
588    }
589}
590
591async fn secure_connection_established(
592    context: &Context,
593    contact_id: ContactId,
594    chat_id: ChatId,
595    timestamp: i64,
596) -> Result<()> {
597    let private_chat_id = ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Yes)
598        .await?
599        .id;
600    private_chat_id
601        .set_protection(
602            context,
603            ProtectionStatus::Protected,
604            timestamp,
605            Some(contact_id),
606        )
607        .await?;
608    context.emit_event(EventType::ChatModified(chat_id));
609    chatlist_events::emit_chatlist_item_changed(context, chat_id);
610    Ok(())
611}
612
613/* ******************************************************************************
614 * Tools: Misc.
615 ******************************************************************************/
616
617fn encrypted_and_signed(
618    context: &Context,
619    mimeparser: &MimeMessage,
620    expected_fingerprint: &Fingerprint,
621) -> bool {
622    if !mimeparser.was_encrypted() {
623        warn!(context, "Message not encrypted.",);
624        false
625    } else if !mimeparser.signatures.contains(expected_fingerprint) {
626        warn!(
627            context,
628            "Message does not match expected fingerprint {}.", expected_fingerprint,
629        );
630        false
631    } else {
632        true
633    }
634}
635
636#[cfg(test)]
637mod securejoin_tests;