Files
chatmail-core/src/securejoin.rs
Hocuri ab08a47298 fix: Handle the case that the user starts a securejoin, and then deletes the contact (#7883)
fix https://github.com/chatmail/core/issues/7880

depends on #7754 (merged)

With this change, a securejoin message is just ignored if the contact
was deleted in the meantime; apparently the user is not interested in
the securejoin process anymore if they deleted the contact.

But other, parallel securejoin processes must not be affected; the test
also tests this.
2026-03-02 22:11:05 +01:00

881 lines
34 KiB
Rust

//! Implementation of [SecureJoin protocols](https://securejoin.delta.chat/).
use anyhow::{Context as _, Error, Result, bail, ensure};
use deltachat_contact_tools::ContactAddress;
use percent_encoding::{AsciiSet, utf8_percent_encode};
use crate::chat::{
self, Chat, ChatId, ChatIdBlocked, add_info_msg, get_chat_id_by_grpid, load_broadcast_secret,
};
use crate::config::Config;
use crate::constants::{
BROADCAST_INCOMPATIBILITY_MSG, Blocked, Chattype, NON_ALPHANUMERIC_WITHOUT_DOT,
};
use crate::contact::mark_contact_id_as_verified;
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::e2ee::ensure_secret_key_exists;
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::key::{DcKey, Fingerprint, load_self_public_key};
use crate::log::LogExt as _;
use crate::log::warn;
use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
use crate::qr::check_qr;
use crate::securejoin::bob::JoinerProgress;
use crate::sync::Sync::*;
use crate::tools::{create_id, create_outgoing_rfc724_mid, time};
use crate::{SecurejoinSource, mimefactory, stats};
use crate::{SecurejoinUiPath, token};
mod bob;
mod qrinvite;
pub(crate) use qrinvite::QrInvite;
use crate::token::Namespace;
/// Only new QR codes cause a verification on Alice's side.
/// When a QR code is too old, it is assumed that there was no direct QR scan,
/// and that the QR code was potentially published on a website,
/// so, Alice doesn't mark Bob as verified.
// TODO For backwards compatibility reasons, this is still using a rather large value.
// Set this to a lower value (e.g. 10 minutes)
// when Delta Chat v2.22.0 is sufficiently rolled out
const VERIFICATION_TIMEOUT_SECONDS: i64 = 7 * 24 * 3600;
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_');
fn inviter_progress(
context: &Context,
contact_id: ContactId,
chat_id: ChatId,
chat_type: Chattype,
) -> Result<()> {
// No other values are used.
let progress = 1000;
context.emit_event(EventType::SecurejoinInviterProgress {
contact_id,
chat_id,
chat_type,
progress,
});
Ok(())
}
/// Shorten name to max. `length` characters.
/// This is to not make QR codes or invite links arbitrary long.
fn shorten_name(name: &str, length: usize) -> String {
if name.chars().count() > length {
// We use _ rather than ... to avoid dots at the end of the URL, which would confuse linkifiers
format!(
"{}_",
&name
.chars()
.take(length.saturating_sub(1))
.collect::<String>()
)
} else {
name.to_string()
}
}
/// Generates a Secure Join QR code.
///
/// With `chat` set to `None` this generates a setup-contact QR code, with `chat` set to a
/// [`ChatId`] generates a join-group/join-broadcast-channel QR code for the given chat.
pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Result<String> {
/*=======================================================
==== Alice - the inviter side ====
==== Step 1 in "Setup verified contact" protocol ====
=======================================================*/
ensure_secret_key_exists(context).await.ok();
let chat = match chat {
Some(id) => {
let chat = Chat::load_from_db(context, id).await?;
ensure!(
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"Can't generate SecureJoin QR code for chat {id} of type {}",
chat.typ
);
if chat.grpid.is_empty() {
let err = format!("Can't generate QR code, chat {id} is a email thread");
error!(context, "get_securejoin_qr: {}.", err);
bail!(err);
}
if chat.typ == Chattype::OutBroadcast {
// If the user created the broadcast before updating Delta Chat,
// then the secret will be missing, and the user needs to recreate the broadcast:
if load_broadcast_secret(context, chat.id).await?.is_none() {
error!(
context,
"Not creating securejoin QR for old broadcast {}, see chat for more info.",
chat.id,
);
let text = BROADCAST_INCOMPATIBILITY_MSG;
add_info_msg(context, chat.id, text).await?;
bail!(text.to_string());
}
}
Some(chat)
}
None => None,
};
let grpid = chat.as_ref().map(|c| c.grpid.as_str());
// Invite number is used to request the inviter key.
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, grpid).await?;
// Auth token is used to verify the key-contact
// if the token is not old
// and add the contact to the group
// if there is an associated group ID.
//
// We always generate a new auth token
// because auth tokens "expire"
// and can only be used to join groups
// without verification afterwards.
let auth = create_id();
token::save(context, Namespace::Auth, grpid, &auth, time()).await?;
let fingerprint = get_self_fingerprint(context).await?.hex();
let self_addr = context.get_primary_self_addr().await?;
let self_addr_urlencoded = utf8_percent_encode(&self_addr, DISALLOWED_CHARACTERS).to_string();
let self_name = context
.get_config(Config::Displayname)
.await?
.unwrap_or_default();
let qr = if let Some(chat) = chat {
context
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await?;
context.scheduler.interrupt_smtp().await;
let chat_name = chat.get_name();
let chat_name_shortened = shorten_name(chat_name, 25);
let chat_name_urlencoded = utf8_percent_encode(&chat_name_shortened, DISALLOWED_CHARACTERS)
.to_string()
.replace("%20", "+");
let grpid = &chat.grpid;
let self_name_shortened = shorten_name(&self_name, 16);
let self_name_urlencoded = utf8_percent_encode(&self_name_shortened, DISALLOWED_CHARACTERS)
.to_string()
.replace("%20", "+");
if chat.typ == Chattype::OutBroadcast {
// For historic reansons, broadcasts currently use j instead of i for the invitenumber.
format!(
"https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&j={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&b={chat_name_urlencoded}",
)
} else {
format!(
"https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&g={chat_name_urlencoded}",
)
}
} else {
let self_name_shortened = shorten_name(&self_name, 25);
let self_name_urlencoded = utf8_percent_encode(&self_name_shortened, DISALLOWED_CHARACTERS)
.to_string()
.replace("%20", "+");
context.sync_qr_code_tokens(None).await?;
context.scheduler.interrupt_smtp().await;
format!(
"https://i.delta.chat/#{fingerprint}&v=3&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}",
)
};
info!(context, "Generated QR code.");
Ok(qr)
}
async fn get_self_fingerprint(context: &Context) -> Result<Fingerprint> {
let key = load_self_public_key(context)
.await
.context("Failed to load key")?;
Ok(key.dc_fingerprint())
}
/// Take a scanned QR-code and do the setup-contact/join-group/invite handshake.
///
/// This is the start of the process for the joiner. See the module and ffi documentation
/// for more details.
///
/// The function returns immediately and the handshake will run in background.
pub async fn join_securejoin(context: &Context, qr: &str) -> Result<ChatId> {
join_securejoin_with_ux_info(context, qr, None, None).await
}
/// Take a scanned QR-code and do the setup-contact/join-group/invite handshake.
///
/// This is the start of the process for the joiner. See the module and ffi documentation
/// for more details.
///
/// The function returns immediately and the handshake will run in background.
///
/// **source** and **uipath** are for statistics-sending,
/// if the user enabled it in the settings;
/// if you don't have statistics-sending implemented, just pass `None` here.
pub async fn join_securejoin_with_ux_info(
context: &Context,
qr: &str,
source: Option<SecurejoinSource>,
uipath: Option<SecurejoinUiPath>,
) -> Result<ChatId> {
let res = securejoin(context, qr).await.map_err(|err| {
warn!(context, "Fatal joiner error: {:#}", err);
// The user just scanned this QR code so has context on what failed.
error!(context, "QR process failed");
err
})?;
stats::count_securejoin_ux_info(context, source, uipath)
.await
.log_err(context)
.ok();
Ok(res)
}
async fn securejoin(context: &Context, qr: &str) -> Result<ChatId> {
/*========================================================
==== Bob - the joiner's side =====
==== Step 2 in "Setup verified contact" protocol =====
========================================================*/
info!(context, "Requesting secure-join ...",);
let qr_scan = check_qr(context, qr).await?;
let invite = QrInvite::try_from(qr_scan)?;
stats::count_securejoin_invite(context, &invite)
.await
.log_err(context)
.ok();
bob::start_protocol(context, invite).await
}
/// Send handshake message from Alice's device.
async fn send_alice_handshake_msg(
context: &Context,
contact_id: ContactId,
step: &str,
) -> Result<()> {
let mut msg = Message {
viewtype: Viewtype::Text,
text: format!("Secure-Join: {step}"),
hidden: true,
..Default::default()
};
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
msg.param.set(Param::Arg, step);
msg.param.set_int(Param::GuaranteeE2ee, 1);
chat::send_msg(
context,
ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Yes)
.await?
.id,
&mut msg,
)
.await?;
Ok(())
}
/// Get an unblocked chat that can be used for info messages.
async fn info_chat_id(context: &Context, contact_id: ContactId) -> Result<ChatId> {
let chat_id_blocked = ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Not).await?;
Ok(chat_id_blocked.id)
}
/// Checks fingerprint and marks the contact as verified
/// if fingerprint matches.
async fn verify_sender_by_fingerprint(
context: &Context,
fingerprint: &Fingerprint,
contact_id: ContactId,
) -> Result<bool> {
let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? else {
return Ok(false);
};
let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint);
if is_verified {
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
}
Ok(is_verified)
}
/// What to do with a Secure-Join handshake message after it was handled.
///
/// This status is returned to [`receive_imf_inner`] which will use it to decide what to do
/// next with this incoming setup-contact/secure-join handshake message.
///
/// [`receive_imf_inner`]: crate::receive_imf::receive_imf_inner
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum HandshakeMessage {
/// The message has been fully handled and should be removed/delete.
///
/// This removes the message both locally and on the IMAP server.
Done,
/// The message should be ignored/hidden, but not removed/deleted.
///
/// This leaves it on the IMAP server. It means other devices on this account can
/// receive and potentially process this message as well. This is useful for example
/// when the other device is running the protocol and has the relevant QR-code
/// information while this device does not have the joiner state.
Ignore,
/// The message should be further processed by incoming message handling.
///
/// This may for example result in a group being created if it is a message which added
/// us to a group (a `vg-member-added` message).
Propagate,
}
/// Step of Secure-Join protocol.
#[derive(Debug, Display, PartialEq, Eq)]
pub(crate) enum SecureJoinStep {
/// vc-request or vg-request; only used in legacy securejoin
Request { invitenumber: String },
/// vc-auth-required or vg-auth-required; only used in legacy securejoin
AuthRequired,
/// vc-request-pubkey; only used in securejoin v3
RequestPubkey,
/// vc-pubkey; only used in securejoin v3
Pubkey,
/// vc-request-with-auth or vg-request-with-auth
RequestWithAuth,
/// vc-contact-confirm
ContactConfirm,
/// vg-member-added
MemberAdded,
/// Deprecated step such as `vg-member-added-received` or `vc-contact-confirm-received`.
Deprecated,
/// Unknown step.
Unknown { step: String },
}
/// Parses message headers to find out which Secure-Join step the message represents.
///
/// Returns `None` if the message is not a Secure-Join message.
pub(crate) fn get_secure_join_step(mime_message: &MimeMessage) -> Option<SecureJoinStep> {
if let Some(invitenumber) = mime_message.get_header(HeaderDef::SecureJoinInvitenumber) {
// We do not care about presence of `Secure-Join: vc-request` or `Secure-Join: vg-request` header.
// This allows us to always treat `Secure-Join` header as protected and ignore it
// in the unencrypted part even though it is sent there for backwards compatibility.
Some(SecureJoinStep::Request {
invitenumber: invitenumber.to_string(),
})
} else if let Some(step) = mime_message.get_header(HeaderDef::SecureJoin) {
match step {
"vc-request-pubkey" => Some(SecureJoinStep::RequestPubkey),
"vc-pubkey" => Some(SecureJoinStep::Pubkey),
"vg-auth-required" | "vc-auth-required" => Some(SecureJoinStep::AuthRequired),
"vg-request-with-auth" | "vc-request-with-auth" => {
Some(SecureJoinStep::RequestWithAuth)
}
"vc-contact-confirm" => Some(SecureJoinStep::ContactConfirm),
"vg-member-added" => Some(SecureJoinStep::MemberAdded),
"vg-member-added-received" | "vc-contact-confirm-received" => {
Some(SecureJoinStep::Deprecated)
}
step => Some(SecureJoinStep::Unknown {
step: step.to_string(),
}),
}
} else {
None
}
}
/// Handle incoming secure-join handshake.
///
/// This function will update the securejoin state in the database as the protocol
/// progresses.
///
/// A message which results in [`Err`] will be hidden from the user but not deleted, it may
/// be a valid message for something else we are not aware off. E.g. it could be part of a
/// handshake performed by another DC app on the same account.
///
/// When `handle_securejoin_handshake()` is called, the message is not yet filed in the
/// database; this is done by `receive_imf()` later on as needed.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn handle_securejoin_handshake(
context: &Context,
mime_message: &mut MimeMessage,
contact_id: ContactId,
) -> Result<HandshakeMessage> {
if contact_id.is_special() {
return Err(Error::msg("Can not be called with special contact ID"));
}
let step = get_secure_join_step(mime_message).context("Not a Secure-Join message")?;
info!(context, "Received secure-join message {step:?}.");
// Opportunistically protect against a theoretical 'surreptitious forwarding' attack:
// If Eve obtains a QR code from Alice and starts a securejoin with her,
// and also lets Bob scan a manipulated QR code,
// she could reencrypt the v*-request-with-auth message to Bob while maintaining the signature,
// and Bob would regard the message as valid.
//
// This attack is not actually relevant in any threat model,
// because if Eve can see Alice's QR code and have Bob scan a manipulated QR code,
// she can just do a classical MitM attack.
//
// Protecting all messages sent by Delta Chat against 'surreptitious forwarding'
// by checking the 'intended recipient fingerprint'
// will improve security (completely unrelated to the securejoin protocol)
// and is something we want to do in the future:
// https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding
if !matches!(
step,
SecureJoinStep::Request { .. } | SecureJoinStep::RequestPubkey | SecureJoinStep::Pubkey
) {
let mut self_found = false;
let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
for (addr, key) in &mime_message.gossiped_keys {
if key.public_key.dc_fingerprint() == self_fingerprint
&& context.is_self_addr(addr).await?
{
self_found = true;
break;
}
}
if !self_found {
// This message isn't intended for us. Possibly the peer doesn't own the key which the
// message is signed with but forwarded someone's message to us.
warn!(context, "Step {step}: No self addr+pubkey gossip found.");
return Ok(HandshakeMessage::Ignore);
}
}
match step {
SecureJoinStep::Request { ref invitenumber } => {
/*=======================================================
==== Alice - the inviter side ====
==== Step 3 in "Setup verified contact" protocol ====
=======================================================*/
// this message may be unencrypted (Bob, the joiner and the sender, might not have Alice's key yet)
// it just ensures, we have Bobs key now. If we do _not_ have the key because eg. MitM has removed it,
// send_message() will fail with the error "End-to-end-encryption unavailable unexpectedly.", so, there is no additional check needed here.
// verify that the `Secure-Join-Invitenumber:`-header matches invitenumber written to the QR code
if !token::exists(context, token::Namespace::InviteNumber, invitenumber).await? {
warn!(context, "Secure-join denied (bad invitenumber).");
return Ok(HandshakeMessage::Ignore);
}
let from_addr = ContactAddress::new(&mime_message.from.addr)?;
let autocrypt_fingerprint = mime_message.autocrypt_fingerprint.as_deref().unwrap_or("");
let (autocrypt_contact_id, _) = Contact::add_or_lookup_ex(
context,
"",
&from_addr,
autocrypt_fingerprint,
Origin::IncomingUnknownFrom,
)
.await?;
let prefix = mime_message
.get_header(HeaderDef::SecureJoin)
.and_then(|step| step.get(..2))
.unwrap_or("vc");
// Alice -> Bob
send_alice_handshake_msg(
context,
autocrypt_contact_id,
&format!("{prefix}-auth-required"),
)
.await
.context("failed sending auth-required handshake message")?;
Ok(HandshakeMessage::Done)
}
SecureJoinStep::AuthRequired => {
/*========================================================
==== Bob - the joiner's side =====
==== Step 4 in "Setup verified contact" protocol =====
========================================================*/
bob::handle_auth_required_or_pubkey(context, mime_message).await
}
SecureJoinStep::RequestPubkey => {
/*========================================================
==== Alice - the inviter's side =====
==== Bob requests our public key (Securejoin v3) =====
========================================================*/
debug_assert!(
mime_message.signature.is_none(),
"RequestPubkey is not supposed to be signed"
);
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
warn!(
context,
"Ignoring {step} message because of missing auth code."
);
return Ok(HandshakeMessage::Ignore);
};
if !token::exists(context, token::Namespace::Auth, auth).await? {
warn!(context, "Secure-join denied (bad auth).");
return Ok(HandshakeMessage::Ignore);
}
let rfc724_mid = create_outgoing_rfc724_mid();
let addr = ContactAddress::new(&mime_message.from.addr)?;
let attach_self_pubkey = true;
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
context,
"vc-pubkey",
&rfc724_mid,
attach_self_pubkey,
auth,
)
.await?;
let msg_id = message::insert_tombstone(context, &rfc724_mid).await?;
insert_into_smtp(context, &rfc724_mid, &addr, rendered_message, msg_id).await?;
context.scheduler.interrupt_smtp().await;
Ok(HandshakeMessage::Done)
}
SecureJoinStep::Pubkey => {
/*========================================================
==== Bob - the joiner's side =====
==== Alice sent us her pubkey (Securejoin v3) =====
========================================================*/
bob::handle_auth_required_or_pubkey(context, mime_message).await
}
SecureJoinStep::RequestWithAuth => {
/*==========================================================
==== Alice - the inviter side ====
==== Steps 5+6 in "Setup verified contact" protocol ====
==== Step 6 in "Out-of-band verified groups" protocol ====
==========================================================*/
// verify that Secure-Join-Fingerprint:-header matches the fingerprint of Bob
let Some(fp) = mime_message.get_header(HeaderDef::SecureJoinFingerprint) else {
warn!(
context,
"Ignoring {step} message because fingerprint is not provided."
);
return Ok(HandshakeMessage::Ignore);
};
let fingerprint: Fingerprint = fp.parse()?;
if !encrypted_and_signed(context, mime_message, &fingerprint) {
warn!(
context,
"Ignoring {step} message because the message is not encrypted."
);
return Ok(HandshakeMessage::Ignore);
}
// verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
warn!(
context,
"Ignoring {step} message because of missing auth code."
);
return Ok(HandshakeMessage::Ignore);
};
let Some((grpid, timestamp)) = context
.sql
.query_row_optional(
"SELECT foreign_key, timestamp FROM tokens WHERE namespc=? AND token=?",
(Namespace::Auth, auth),
|row| {
let foreign_key: String = row.get(0)?;
let timestamp: i64 = row.get(1)?;
Ok((foreign_key, timestamp))
},
)
.await?
else {
warn!(
context,
"Ignoring {step} message because of invalid auth code."
);
return Ok(HandshakeMessage::Ignore);
};
let joining_chat_id = match grpid.as_str() {
"" => None,
id => {
let Some((chat_id, ..)) = get_chat_id_by_grpid(context, id).await? else {
warn!(context, "Ignoring {step} message: unknown grpid {id}.",);
return Ok(HandshakeMessage::Ignore);
};
Some(chat_id)
}
};
let sender_contact = Contact::get_by_id(context, contact_id).await?;
if sender_contact
.fingerprint()
.is_none_or(|fp| fp != fingerprint)
{
warn!(
context,
"Ignoring {step} message because of fingerprint mismatch."
);
return Ok(HandshakeMessage::Ignore);
}
info!(context, "Fingerprint verified via Auth code.",);
// Mark the contact as verified if auth code is less than VERIFICATION_TIMEOUT_SECONDS seconds old.
if time() < timestamp + VERIFICATION_TIMEOUT_SECONDS {
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
}
contact_id.regossip_keys(context).await?;
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
// for setup-contact, make Alice's one-to-one chat with Bob visible
// (secure-join-information are shown in the group chat)
if grpid.is_empty() {
ChatId::create_for_contact(context, contact_id).await?;
}
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
if let Some(joining_chat_id) = joining_chat_id {
// Join group.
chat::add_contact_to_chat_ex(context, Nosync, joining_chat_id, contact_id, true)
.await?;
let chat = Chat::load_from_db(context, joining_chat_id).await?;
if chat.typ == Chattype::OutBroadcast {
// We don't use the membership consistency algorithm for broadcast channels,
// so, sync the memberlist when adding a contact
chat.sync_contacts(context).await.log_err(context).ok();
}
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
// IMAP-delete the message to avoid handling it by another device and adding the
// member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done)
} else {
let chat_id = info_chat_id(context, contact_id).await?;
// Setup verified contact.
send_alice_handshake_msg(context, contact_id, "vc-contact-confirm")
.await
.context("failed sending vc-contact-confirm message")?;
inviter_progress(context, contact_id, chat_id, Chattype::Single)?;
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
}
}
/*=======================================================
==== Bob - the joiner's side ====
==== Step 7 in "Setup verified contact" protocol ====
=======================================================*/
SecureJoinStep::ContactConfirm => {
context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id,
progress: JoinerProgress::Succeeded.into_u16(),
});
Ok(HandshakeMessage::Ignore)
}
SecureJoinStep::MemberAdded => {
let Some(member_added) = mime_message.get_header(HeaderDef::ChatGroupMemberAdded)
else {
warn!(
context,
"vg-member-added without Chat-Group-Member-Added header."
);
return Ok(HandshakeMessage::Propagate);
};
if !context.is_self_addr(member_added).await? {
info!(
context,
"Member {member_added} added by unrelated SecureJoin process."
);
return Ok(HandshakeMessage::Propagate);
}
context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id,
progress: JoinerProgress::Succeeded.into_u16(),
});
Ok(HandshakeMessage::Propagate)
}
SecureJoinStep::Deprecated => {
// Deprecated steps, delete them immediately.
Ok(HandshakeMessage::Done)
}
SecureJoinStep::Unknown { ref step } => {
warn!(context, "Invalid SecureJoin step: {step:?}.");
Ok(HandshakeMessage::Ignore)
}
}
}
async fn insert_into_smtp(
context: &Context,
rfc724_mid: &str,
recipient: &str,
rendered_message: String,
msg_id: MsgId,
) -> Result<(), Error> {
context
.sql
.execute(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id)
VALUES (?1, ?2, ?3, ?4)",
(&rfc724_mid, &recipient, &rendered_message, msg_id),
)
.await?;
Ok(())
}
/// Observe self-sent Securejoin message.
///
/// In a multi-device-setup, there may be other devices that "see" the handshake messages.
/// If we see self-sent messages encrypted+signed correctly with our key,
/// we can make some conclusions of it.
///
/// If we see self-sent {vc,vg}-request-with-auth,
/// we know that we are Bob (joiner-observer)
/// that just marked peer (Alice) as verified
/// either after receiving {vc,vg}-auth-required
/// or immediately after scanning the QR-code
/// if the key was already known.
///
/// If we see self-sent vc-contact-confirm or vg-member-added message,
/// we know that we are Alice (inviter-observer)
/// that just marked peer (Bob) as verified
/// in response to correct vc-request-with-auth message.
pub(crate) async fn observe_securejoin_on_other_device(
context: &Context,
mime_message: &MimeMessage,
contact_id: ContactId,
) -> Result<HandshakeMessage> {
if contact_id.is_special() {
return Err(Error::msg("Can not be called with special contact ID"));
}
let step = get_secure_join_step(mime_message).context("Not a Secure-Join message")?;
info!(context, "Observing secure-join message {step:?}.");
match step {
SecureJoinStep::Request { .. }
| SecureJoinStep::AuthRequired
| SecureJoinStep::RequestPubkey
| SecureJoinStep::Pubkey
| SecureJoinStep::Deprecated
| SecureJoinStep::Unknown { .. } => {
return Ok(HandshakeMessage::Ignore);
}
SecureJoinStep::RequestWithAuth
| SecureJoinStep::MemberAdded
| SecureJoinStep::ContactConfirm => {}
}
if !encrypted_and_signed(context, mime_message, &get_self_fingerprint(context).await?) {
warn!(
context,
"Observed SecureJoin message is not encrypted correctly."
);
return Ok(HandshakeMessage::Ignore);
}
let contact = Contact::get_by_id(context, contact_id).await?;
let addr = contact.get_addr().to_lowercase();
let Some(key) = mime_message.gossiped_keys.get(&addr) else {
warn!(context, "No gossip header for {addr} at step {step}.");
return Ok(HandshakeMessage::Ignore);
};
let Some(contact_fingerprint) = contact.fingerprint() else {
// Not a key-contact, should not happen.
warn!(context, "Contact does not have a fingerprint.");
return Ok(HandshakeMessage::Ignore);
};
if key.public_key.dc_fingerprint() != contact_fingerprint {
// Fingerprint does not match, ignore.
warn!(context, "Fingerprint does not match.");
return Ok(HandshakeMessage::Ignore);
}
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
if matches!(
step,
SecureJoinStep::MemberAdded | SecureJoinStep::ContactConfirm
) {
let chat_type = if mime_message
.get_header(HeaderDef::ChatGroupMemberAdded)
.is_none()
{
Chattype::Single
} else if mime_message.get_header(HeaderDef::ListId).is_some() {
Chattype::OutBroadcast
} else {
Chattype::Group
};
// We don't know the chat ID
// as we may not know about the group yet.
//
// Event is mostly used for bots
// which only have a single device
// and tests which don't care about the chat ID,
// so we pass invalid chat ID here.
let chat_id = ChatId::new(0);
inviter_progress(context, contact_id, chat_id, chat_type)?;
}
if matches!(step, SecureJoinStep::RequestWithAuth) {
// This actually reflects what happens on the first device (which does the secure
// join) and causes a subsequent "vg-member-added" message to create an unblocked
// verified group.
ChatId::create_for_contact_with_blocked(context, contact_id, Blocked::Not).await?;
}
if matches!(step, SecureJoinStep::MemberAdded) {
Ok(HandshakeMessage::Propagate)
} else {
Ok(HandshakeMessage::Ignore)
}
}
/* ******************************************************************************
* Tools: Misc.
******************************************************************************/
fn encrypted_and_signed(
context: &Context,
mimeparser: &MimeMessage,
expected_fingerprint: &Fingerprint,
) -> bool {
if let Some((signature, _)) = mimeparser.signature.as_ref() {
if signature == expected_fingerprint {
true
} else {
warn!(
context,
"Message does not match expected fingerprint {expected_fingerprint}.",
);
false
}
} else {
warn!(context, "Message not encrypted.",);
false
}
}
#[cfg(test)]
mod securejoin_tests;