Files
chatmail-core/src/securejoin.rs
link2xt a947f4296f refactor(securejoin): do not check for self address in forwarding protection
If our key is gossiped, the message is intended for us.
The check for address is redundant for incoming messages as
if we received the message then it was addressed to us.

This whole protection code can eventually be removed
as we have intended recipient fingerprints already,
it only protects against forwarding of messages
sent by old clients.
2026-03-28 16:20:39 +00:00

883 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, self_fingerprint};
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 key in mime_message.gossiped_keys.values() {
if key.public_key.dc_fingerprint() == self_fingerprint {
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 self_fp = self_fingerprint(context).await?;
let shared_secret = format!("securejoin/{self_fp}/{auth}");
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
context,
"vc-pubkey",
&rfc724_mid,
attach_self_pubkey,
auth,
&shared_secret,
)
.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?;
// 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?;
}
if let Some(joining_chat_id) = joining_chat_id {
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();
} else {
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited)
.await?;
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
}
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;