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