Files
chatmail-core/src/securejoin/bob.rs
bjoern 97b0d09ed2 feat: get contact-id for info messages (#6714)
instead of showing addresses in info message, provide an API to get the
contact-id.

UI can then make the info message tappable and open the contact profile
in scope

the corresponding iOS PR - incl. **screencast** - is at
https://github.com/deltachat/deltachat-ios/pull/2652 ; jsonrpc can come
in a subsequent PR when things are settled on android/ios

the number of parameters in `add_info_msg_with_cmd` gets bigger and
bigger, however, i did not want to refactor this in this PR. it is also
not really adding complexity



closes #6702

---------

Co-authored-by: link2xt <link2xt@testrun.org>
Co-authored-by: Hocuri <hocuri@gmx.de>
2025-03-31 18:56:57 +02:00

393 lines
14 KiB
Rust

//! Bob's side of SecureJoin handling, the joiner-side.
use anyhow::{Context as _, Result};
use super::qrinvite::QrInvite;
use super::HandshakeMessage;
use crate::chat::{self, is_contact_in_chat, ChatId, ProtectionStatus};
use crate::constants::{self, Blocked, Chattype};
use crate::contact::Origin;
use crate::context::Context;
use crate::events::EventType;
use crate::key::{load_self_public_key, DcKey};
use crate::message::{Message, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
use crate::securejoin::{encrypted_and_signed, verify_sender_by_fingerprint, ContactId};
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{create_smeared_timestamp, time};
/// Starts the securejoin protocol with the QR `invite`.
///
/// This will try to start the securejoin protocol for the given QR `invite`.
///
/// If Bob already has Alice's key, he sends `AUTH` token
/// and forgets about the invite.
/// If Bob does not yet have Alice's key, he sends `vc-request`
/// or `vg-request` message and stores a row in the `bobstate` table
/// so he can check Alice's key against the fingerprint
/// and send `AUTH` token later.
///
/// This function takes care of handling multiple concurrent joins and handling errors while
/// starting the protocol.
///
/// # Bob - the joiner's side
/// ## Step 2 in the "Setup Contact protocol", section 2.1 of countermitm 0.10.0
///
/// # Returns
///
/// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1
/// chat with Alice, for a SecureJoin QR this is the group chat.
pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Result<ChatId> {
// A 1:1 chat is needed to send messages to Alice. When joining a group this chat is
// hidden, if a user starts sending messages in it it will be unhidden in
// receive_imf.
let hidden = match invite {
QrInvite::Contact { .. } => Blocked::Not,
QrInvite::Group { .. } => Blocked::Yes,
};
let chat_id = ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
.await
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
context.emit_event(EventType::ContactsChanged(None));
// Now start the protocol and initialise the state.
{
let peer_verified =
verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id())
.await?;
if peer_verified {
// The scanned fingerprint matches Alice's key, we can proceed to step 4b.
info!(context, "Taking securejoin protocol shortcut");
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth)
.await?;
// Mark 1:1 chat as verified already.
chat_id
.set_protection(
context,
ProtectionStatus::Protected,
time(),
Some(invite.contact_id()),
)
.await?;
context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id: invite.contact_id(),
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
});
} else {
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::Request).await?;
insert_new_db_entry(context, invite.clone(), chat_id).await?;
}
}
match invite {
QrInvite::Group { .. } => {
// For a secure-join we need to create the group and add the contact. The group will
// only become usable once the protocol is finished.
let group_chat_id = joining_chat_id(context, &invite, chat_id).await?;
if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? {
chat::add_to_chat_contacts_table(
context,
time(),
group_chat_id,
&[invite.contact_id()],
)
.await?;
}
let msg = stock_str::secure_join_started(context, invite.contact_id()).await;
chat::add_info_msg(context, group_chat_id, &msg, time()).await?;
Ok(group_chat_id)
}
QrInvite::Contact { .. } => {
// For setup-contact the BobState already ensured the 1:1 chat exists because it
// uses it to send the handshake messages.
// Calculate the sort timestamp before checking the chat protection status so that if we
// race with its change, we don't add our message below the protection message.
let sort_to_bottom = true;
let (received, incoming) = (false, false);
let ts_sort = chat_id
.calc_sort_timestamp(context, 0, sort_to_bottom, received, incoming)
.await?;
if chat_id.is_protected(context).await? == ProtectionStatus::Unprotected {
let ts_start = time();
chat::add_info_msg_with_cmd(
context,
chat_id,
&stock_str::securejoin_wait(context).await,
SystemMessage::SecurejoinWait,
ts_sort,
Some(ts_start),
None,
None,
None,
)
.await?;
chat_id.spawn_securejoin_wait(context, constants::SECUREJOIN_WAIT_TIMEOUT);
}
Ok(chat_id)
}
}
}
/// Inserts a new entry in the bobstate table.
///
/// Returns the ID of the newly inserted entry.
async fn insert_new_db_entry(context: &Context, invite: QrInvite, chat_id: ChatId) -> Result<i64> {
context
.sql
.insert(
"INSERT INTO bobstate (invite, next_step, chat_id) VALUES (?, ?, ?);",
(invite, 0, chat_id),
)
.await
}
/// Handles `vc-auth-required` and `vg-auth-required` handshake messages.
///
/// # Bob - the joiner's side
/// ## Step 4 in the "Setup Contact protocol"
pub(super) async fn handle_auth_required(
context: &Context,
message: &MimeMessage,
) -> Result<HandshakeMessage> {
// Load all Bob states that expect `vc-auth-required` or `vg-auth-required`.
let bob_states: Vec<(i64, QrInvite, ChatId)> = context
.sql
.query_map(
"SELECT id, invite, chat_id FROM bobstate",
(),
|row| {
let row_id: i64 = row.get(0)?;
let invite: QrInvite = row.get(1)?;
let chat_id: ChatId = row.get(2)?;
Ok((row_id, invite, chat_id))
},
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
info!(
context,
"Bob Step 4 - handling {{vc,vg}}-auth-required message."
);
let mut auth_sent = false;
for (bobstate_row_id, invite, chat_id) in bob_states {
if !encrypted_and_signed(context, message, invite.fingerprint()) {
continue;
}
if !verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await?
{
continue;
}
info!(context, "Fingerprint verified.",);
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth).await?;
context
.sql
.execute("DELETE FROM bobstate WHERE id=?", (bobstate_row_id,))
.await?;
match invite {
QrInvite::Contact { .. } => {}
QrInvite::Group { .. } => {
// The message reads "Alice replied, waiting to be added to the group…",
// so only show it on secure-join and not on setup-contact.
let contact_id = invite.contact_id();
let msg = stock_str::secure_join_replies(context, contact_id).await;
let chat_id = joining_chat_id(context, &invite, chat_id).await?;
chat::add_info_msg(context, chat_id, &msg, time()).await?;
}
}
chat_id
.set_protection(
context,
ProtectionStatus::Protected,
message.timestamp_sent,
Some(invite.contact_id()),
)
.await?;
context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id: invite.contact_id(),
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
});
auth_sent = true;
}
if auth_sent {
// Delete the message from IMAP server.
Ok(HandshakeMessage::Done)
} else {
// We have not found any corresponding AUTH codes,
// maybe another Bob device has scanned the QR code.
// Leave the message on IMAP server and let the other device
// process it.
Ok(HandshakeMessage::Ignore)
}
}
/// Sends the requested handshake message to Alice.
pub(crate) async fn send_handshake_message(
context: &Context,
invite: &QrInvite,
chat_id: ChatId,
step: BobHandshakeMsg,
) -> Result<()> {
let mut msg = Message {
viewtype: Viewtype::Text,
text: step.body_text(invite),
hidden: true,
..Default::default()
};
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
// Sends the step in Secure-Join header.
msg.param.set(Param::Arg, step.securejoin_header(invite));
match step {
BobHandshakeMsg::Request => {
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
msg.param.set(Param::Arg2, invite.invitenumber());
msg.force_plaintext();
}
BobHandshakeMsg::RequestWithAuth => {
// Sends the Secure-Join-Auth header in mimefactory.rs.
msg.param.set(Param::Arg2, invite.authcode());
msg.param.set_int(Param::GuaranteeE2ee, 1);
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
let bob_fp = load_self_public_key(context).await?.dc_fingerprint();
msg.param.set(Param::Arg3, bob_fp.hex());
// Sends the grpid in the Secure-Join-Group header.
//
// `Secure-Join-Group` header is deprecated,
// but old Delta Chat core requires that Alice receives it.
//
// Previous Delta Chat core also sent `Secure-Join-Group` header
// in `vg-request` messages,
// but it was not used on the receiver.
if let QrInvite::Group { ref grpid, .. } = invite {
msg.param.set(Param::Arg4, grpid);
}
}
};
chat::send_msg(context, chat_id, &mut msg).await?;
Ok(())
}
/// Identifies the SecureJoin handshake messages Bob can send.
pub(crate) enum BobHandshakeMsg {
/// vc-request or vg-request
Request,
/// vc-request-with-auth or vg-request-with-auth
RequestWithAuth,
}
impl BobHandshakeMsg {
/// Returns the text to send in the body of the handshake message.
///
/// This text has no significance to the protocol, but would be visible if users see
/// this email message directly, e.g. when accessing their email without using
/// DeltaChat.
fn body_text(&self, invite: &QrInvite) -> String {
format!("Secure-Join: {}", self.securejoin_header(invite))
}
/// Returns the `Secure-Join` header value.
///
/// This identifies the step this message is sending information about. Most protocol
/// steps include additional information into other headers, see
/// [`send_handshake_message`] for these.
fn securejoin_header(&self, invite: &QrInvite) -> &'static str {
match self {
Self::Request => match invite {
QrInvite::Contact { .. } => "vc-request",
QrInvite::Group { .. } => "vg-request",
},
Self::RequestWithAuth => match invite {
QrInvite::Contact { .. } => "vc-request-with-auth",
QrInvite::Group { .. } => "vg-request-with-auth",
},
}
}
}
/// Returns the [`ChatId`] of the chat being joined.
///
/// This is the chat in which you want to notify the user as well.
///
/// When joining a group this is the [`ChatId`] of the group chat, when verifying a
/// contact this is the [`ChatId`] of the 1:1 chat.
/// The group chat will be created if it does not yet exist.
async fn joining_chat_id(
context: &Context,
invite: &QrInvite,
alice_chat_id: ChatId,
) -> Result<ChatId> {
match invite {
QrInvite::Contact { .. } => Ok(alice_chat_id),
QrInvite::Group {
ref grpid,
ref name,
..
} => {
let group_chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? {
Some((chat_id, _protected, _blocked)) => {
chat_id.unblock_ex(context, Nosync).await?;
chat_id
}
None => {
ChatId::create_multiuser_record(
context,
Chattype::Group,
grpid,
name,
Blocked::Not,
ProtectionStatus::Unprotected, // protection is added later as needed
None,
create_smeared_timestamp(context),
)
.await?
}
};
Ok(group_chat_id)
}
}
}
/// Progress updates for [`EventType::SecurejoinJoinerProgress`].
///
/// This has an `From<JoinerProgress> for usize` impl yielding numbers between 0 and a 1000
/// which can be shown as a progress bar.
pub(crate) enum JoinerProgress {
/// vg-vc-request-with-auth sent.
///
/// Typically shows as "alice@addr verified, introducing myself."
RequestWithAuthSent,
/// Completed securejoin.
Succeeded,
}
impl JoinerProgress {
#[expect(clippy::wrong_self_convention)]
pub(crate) fn to_usize(self) -> usize {
match self {
JoinerProgress::RequestWithAuthSent => 400,
JoinerProgress::Succeeded => 1000,
}
}
}