feat: allow scanning multiple securejoin QR codes in parallel

This commit is contained in:
link2xt
2025-02-11 19:42:53 +00:00
parent 449ba4e192
commit bbb267331c
7 changed files with 422 additions and 685 deletions

View File

@@ -1,31 +1,40 @@
//! Bob's side of SecureJoin handling.
//!
//! This are some helper functions around [`BobState`] which augment the state changes with
//! the required user interactions.
//! Bob's side of SecureJoin handling, the joiner-side.
use anyhow::{Context as _, Result};
use super::bobstate::{BobHandshakeStage, BobState};
use super::qrinvite::QrInvite;
use super::HandshakeMessage;
use crate::chat::{is_contact_in_chat, ChatId, ProtectionStatus};
use crate::chat::{self, is_contact_in_chat, ChatId, ProtectionStatus};
use crate::constants::{self, Blocked, Chattype};
use crate::contact::Contact;
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};
use crate::{chat, stock_str};
/// Starts the securejoin protocol with the QR `invite`.
///
/// This will try to start the securejoin protocol for the given QR `invite`. If it
/// succeeded the protocol state will be tracked in `self`.
/// 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
@@ -42,22 +51,47 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
.await
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
// Now start the protocol and initialise the state
let (state, stage, aborted_states) =
BobState::start_protocol(context, invite.clone(), chat_id).await?;
for state in aborted_states {
error!(context, "Aborting previously unfinished QR Join process.");
state.notify_aborted(context, "New QR code scanned").await?;
state.emit_progress(context, JoinerProgress::Error);
}
if matches!(stage, BobHandshakeStage::RequestWithAuthSent) {
state.emit_progress(context, JoinerProgress::RequestWithAuthSent);
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 = state.joining_chat_id(context).await?;
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,
@@ -74,7 +108,6 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
QrInvite::Contact { .. } => {
// For setup-contact the BobState already ensured the 1:1 chat exists because it
// uses it to send the handshake messages.
let chat_id = state.alice_chat();
// 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;
@@ -102,6 +135,19 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
}
}
/// 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
@@ -110,121 +156,214 @@ pub(super) async fn handle_auth_required(
context: &Context,
message: &MimeMessage,
) -> Result<HandshakeMessage> {
let Some(mut bobstate) = BobState::from_db(&context.sql).await? else {
return Ok(HandshakeMessage::Ignore);
};
// 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?;
match bobstate.handle_auth_required(context, message).await? {
Some(BobHandshakeStage::Terminated(why)) => {
bobstate.notify_aborted(context, why).await?;
Ok(HandshakeMessage::Done)
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;
}
Some(_stage) => {
if bobstate.is_join_group() {
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 = bobstate.invite().contact_id();
let contact_id = invite.contact_id();
let msg = stock_str::secure_join_replies(context, contact_id).await;
let chat_id = bobstate.joining_chat_id(context).await?;
let chat_id = joining_chat_id(context, &invite, chat_id).await?;
chat::add_info_msg(context, chat_id, &msg, time()).await?;
}
bobstate
.set_peer_verified(context, message.timestamp_sent)
.await?;
bobstate.emit_progress(context, JoinerProgress::RequestWithAuthSent);
Ok(HandshakeMessage::Done)
}
None => Ok(HandshakeMessage::Ignore),
}
}
/// Private implementations for user interactions about this [`BobState`].
impl BobState {
fn is_join_group(&self) -> bool {
match self.invite() {
QrInvite::Contact { .. } => false,
QrInvite::Group { .. } => true,
}
}
pub(crate) fn emit_progress(&self, context: &Context, progress: JoinerProgress) {
let contact_id = self.invite().contact_id();
context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id,
progress: progress.into(),
});
}
/// 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 1:1 chat is assumed to exist
/// because a [`BobState`] can not exist without, the group chat will be created if it
/// does not yet exist.
async fn joining_chat_id(&self, context: &Context) -> Result<ChatId> {
match self.invite() {
QrInvite::Contact { .. } => Ok(self.alice_chat()),
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)
}
}
}
/// Notifies the user that the SecureJoin was aborted.
///
/// This creates an info message in the chat being joined.
async fn notify_aborted(&self, context: &Context, why: &str) -> Result<()> {
let contact = Contact::get_by_id(context, self.invite().contact_id()).await?;
let mut msg = stock_str::contact_not_verified(context, &contact).await;
msg += " (";
msg += why;
msg += ")";
let chat_id = self.joining_chat_id(context).await?;
chat::add_info_msg(context, chat_id, &msg, time()).await?;
warn!(
context,
"StockMessage::ContactNotVerified posted to joining chat ({})", why
);
Ok(())
}
/// Turns 1:1 chat with SecureJoin peer into protected chat.
pub(crate) async fn set_peer_verified(&self, context: &Context, timestamp: i64) -> Result<()> {
let contact = Contact::get_by_id(context, self.invite().contact_id()).await?;
self.alice_chat()
chat_id
.set_protection(
context,
ProtectionStatus::Protected,
timestamp,
Some(contact.id),
message.timestamp_sent,
Some(invite.contact_id()),
)
.await?;
Ok(())
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)
}
}
}
@@ -233,8 +372,6 @@ impl BobState {
/// 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 {
/// An error occurred.
Error,
/// vg-vc-request-with-auth sent.
///
/// Typically shows as "alice@addr verified, introducing myself."
@@ -243,10 +380,10 @@ pub(crate) enum JoinerProgress {
Succeeded,
}
impl From<JoinerProgress> for usize {
fn from(progress: JoinerProgress) -> Self {
match progress {
JoinerProgress::Error => 0,
impl JoinerProgress {
#[expect(clippy::wrong_self_convention)]
pub(crate) fn to_usize(self) -> usize {
match self {
JoinerProgress::RequestWithAuthSent => 400,
JoinerProgress::Succeeded => 1000,
}

View File

@@ -1,517 +0,0 @@
//! Secure-Join protocol state machine for Bob, the joiner-side.
//!
//! This module contains the state machine to run the Secure-Join handshake for Bob and does
//! not do any user interaction required by the protocol. Instead the state machine
//! provides all the information to its driver so it can perform the correct interactions.
//!
//! The [`BobState`] is only directly used to initially create it when starting the
//! protocol.
use anyhow::Result;
use rusqlite::Connection;
use super::qrinvite::QrInvite;
use super::{encrypted_and_signed, verify_sender_by_fingerprint};
use crate::chat::{self, ChatId};
use crate::config::Config;
use crate::contact::{ContactId, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::HeaderDef;
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::Peerstate;
use crate::sql::Sql;
use crate::tools::time;
/// The stage of the [`BobState`] securejoin handshake protocol state machine.
///
/// This does not concern itself with user interactions, only represents what happened to
/// the protocol state machine from handling this message.
#[derive(Clone, Copy, Debug, Display)]
pub enum BobHandshakeStage {
/// Step 2 completed: (vc|vg)-request message sent.
RequestSent,
/// Step 4 completed: (vc|vg)-request-with-auth message sent.
RequestWithAuthSent,
/// The protocol prematurely terminated with given reason.
Terminated(&'static str),
}
/// The securejoin state kept while Bob is joining.
///
/// This is stored in the database and loaded from there using [`BobState::from_db`]. To
/// create a new one use [`BobState::start_protocol`].
///
/// This purposefully has nothing optional, the state is always fully valid. However once a
/// terminal state is reached in [`BobState::next`] the entry in the database will already
/// have been deleted.
///
/// # Conducting the securejoin handshake
///
/// The methods on this struct allow you to interact with the state and thus conduct the
/// securejoin handshake for Bob. The methods only concern themselves with the protocol
/// state and explicitly avoid performing any user interactions required by securejoin.
/// This simplifies the concerns and logic required in both the callers and in the state
/// management. The return values can be used to understand what user interactions need to
/// happen.
///
/// [`Bob`]: super::Bob
/// [`Bob::state`]: super::Bob::state
#[derive(Debug, Clone)]
pub struct BobState {
/// Database primary key.
id: i64,
/// The QR Invite code.
invite: QrInvite,
/// The next expected message from Alice.
next: SecureJoinStep,
/// The [`ChatId`] of the 1:1 chat with Alice, matching [`QrInvite::contact_id`].
chat_id: ChatId,
}
impl BobState {
/// Starts the securejoin protocol and creates a new [`BobState`].
///
/// The `chat_id` needs to be the ID of the 1:1 chat with Alice, this chat will be used
/// to exchange the SecureJoin handshake messages as well as for showing error messages.
///
/// # Bob - the joiner's side
/// ## Step 2 in the "Setup Contact protocol", section 2.1 of countermitm 0.10.0
///
/// This currently aborts any other securejoin process if any did not yet complete. The
/// ChatIds of the relevant 1:1 chat of any aborted handshakes are returned so that you
/// can report the aboreted handshake in the chat. (Yes, there can only ever be one
/// ChatId in that Vec, the database doesn't care though.)
pub async fn start_protocol(
context: &Context,
invite: QrInvite,
chat_id: ChatId,
) -> Result<(Self, BobHandshakeStage, Vec<Self>)> {
let peer_verified =
verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id())
.await?;
let (stage, next);
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?;
stage = BobHandshakeStage::RequestWithAuthSent;
next = SecureJoinStep::ContactConfirm;
} else {
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::Request).await?;
stage = BobHandshakeStage::RequestSent;
next = SecureJoinStep::AuthRequired;
};
let (id, aborted_states) =
Self::insert_new_db_entry(context, next, invite.clone(), chat_id).await?;
let state = Self {
id,
invite,
next,
chat_id,
};
if peer_verified {
// Mark 1:1 chat as verified already.
state.set_peer_verified(context, time()).await?;
}
Ok((state, stage, aborted_states))
}
/// Inserts a new entry in the bobstate table, deleting all previous entries.
///
/// Returns the ID of the newly inserted entry and all the aborted states.
async fn insert_new_db_entry(
context: &Context,
next: SecureJoinStep,
invite: QrInvite,
chat_id: ChatId,
) -> Result<(i64, Vec<Self>)> {
context
.sql
.transaction(move |transaction| {
// We need to start a write transaction right away, so that we have the
// database locked and no one else can write to this table while we read the
// rows that we will delete. So start with a dummy UPDATE.
transaction.execute(
r#"UPDATE bobstate SET next_step=?;"#,
(SecureJoinStep::Terminated,),
)?;
let mut stmt = transaction.prepare("SELECT id FROM bobstate;")?;
let mut aborted = Vec::new();
for id in stmt.query_map((), |row| row.get::<_, i64>(0))? {
let id = id?;
let state = BobState::from_db_id(transaction, id)?;
aborted.push(state);
}
// Finally delete everything and insert new row.
transaction.execute("DELETE FROM bobstate;", ())?;
transaction.execute(
"INSERT INTO bobstate (invite, next_step, chat_id) VALUES (?, ?, ?);",
(invite, next, chat_id),
)?;
let id = transaction.last_insert_rowid();
Ok((id, aborted))
})
.await
}
/// Load [`BobState`] from the database.
pub async fn from_db(sql: &Sql) -> Result<Option<Self>> {
// Because of how Self::start_protocol() updates the database we are currently
// guaranteed to only have one row.
sql.query_row_optional(
"SELECT id, invite, next_step, chat_id FROM bobstate;",
(),
|row| {
let s = BobState {
id: row.get(0)?,
invite: row.get(1)?,
next: row.get(2)?,
chat_id: row.get(3)?,
};
Ok(s)
},
)
.await
}
fn from_db_id(connection: &Connection, id: i64) -> rusqlite::Result<Self> {
connection.query_row(
"SELECT invite, next_step, chat_id FROM bobstate WHERE id=?;",
(id,),
|row| {
let s = BobState {
id,
invite: row.get(0)?,
next: row.get(1)?,
chat_id: row.get(2)?,
};
Ok(s)
},
)
}
/// Returns the [`QrInvite`] used to create this [`BobState`].
pub fn invite(&self) -> &QrInvite {
&self.invite
}
/// Returns the [`ChatId`] of the 1:1 chat with the inviter (Alice).
pub fn alice_chat(&self) -> ChatId {
self.chat_id
}
/// Updates the [`BobState::next`] field in memory and the database.
///
/// If the next state is a terminal state it will remove this [`BobState`] from the
/// database.
///
/// If a user scanned a new QR code after this [`BobState`] was loaded this update will
/// fail currently because starting a new joiner process currently kills any previously
/// running processes. This is a limitation which will go away in the future.
async fn update_next(&mut self, sql: &Sql, next: SecureJoinStep) -> Result<()> {
// TODO: write test verifying how this would fail.
match next {
SecureJoinStep::AuthRequired | SecureJoinStep::ContactConfirm => {
sql.execute(
"UPDATE bobstate SET next_step=? WHERE id=?;",
(next, self.id),
)
.await?;
}
SecureJoinStep::Terminated | SecureJoinStep::Completed => {
sql.execute("DELETE FROM bobstate WHERE id=?;", (self.id,))
.await?;
}
}
self.next = next;
Ok(())
}
/// Handles {vc,vg}-auth-required message of the securejoin handshake for Bob.
///
/// If the message was not used for this handshake `None` is returned, otherwise the new
/// stage is returned. Once [`BobHandshakeStage::Terminated`] is reached this
/// [`BobState`] should be destroyed,
/// further calling it will just result in the messages being unused by this handshake.
pub(crate) async fn handle_auth_required(
&mut self,
context: &Context,
mime_message: &MimeMessage,
) -> Result<Option<BobHandshakeStage>> {
let step = match mime_message.get_header(HeaderDef::SecureJoin) {
Some(step) => step,
None => {
warn!(
context,
"Message has no Secure-Join header: {}",
mime_message.get_rfc724_mid().unwrap_or_default()
);
return Ok(None);
}
};
if !self.is_msg_expected(context, step) {
info!(context, "{} message out of sync for BobState", step);
return Ok(None);
}
info!(
context,
"Bob Step 4 - handling {{vc,vg}}-auth-required message."
);
if !encrypted_and_signed(context, mime_message, self.invite.fingerprint()) {
let reason = if mime_message.was_encrypted() {
"Valid signature missing"
} else {
"Required encryption missing"
};
self.update_next(&context.sql, SecureJoinStep::Terminated)
.await?;
return Ok(Some(BobHandshakeStage::Terminated(reason)));
}
if !verify_sender_by_fingerprint(
context,
self.invite.fingerprint(),
self.invite.contact_id(),
)
.await?
{
self.update_next(&context.sql, SecureJoinStep::Terminated)
.await?;
return Ok(Some(BobHandshakeStage::Terminated("Fingerprint mismatch")));
}
info!(context, "Fingerprint verified.",);
self.update_next(&context.sql, SecureJoinStep::ContactConfirm)
.await?;
self.send_handshake_message(context, BobHandshakeMsg::RequestWithAuth)
.await?;
Ok(Some(BobHandshakeStage::RequestWithAuthSent))
}
/// Returns `true` if the message is expected according to the protocol.
pub(crate) fn is_msg_expected(&self, context: &Context, step: &str) -> bool {
let variant_matches = match self.invite {
QrInvite::Contact { .. } => step.starts_with("vc-"),
QrInvite::Group { .. } => step.starts_with("vg-"),
};
let step_matches = self.next.matches(context, step);
variant_matches && step_matches
}
/// Handles a *vc-contact-confirm* or *vg-member-added* message.
///
/// # Bob - the joiner's side
/// ## Step 7 in the "Setup Contact protocol", section 2.1 of countermitm 0.10.0
pub(crate) async fn step_contact_confirm(&mut self, context: &Context) -> Result<()> {
let fingerprint = self.invite.fingerprint();
let Some(ref mut peerstate) = Peerstate::from_fingerprint(context, fingerprint).await?
else {
return Ok(());
};
// Mark peer as backward verified.
peerstate.backward_verified_key_id =
Some(context.get_config_i64(Config::KeyId).await?).filter(|&id| id > 0);
peerstate.save_to_db(&context.sql).await?;
ContactId::scaleup_origin(
context,
&[self.invite.contact_id()],
Origin::SecurejoinJoined,
)
.await?;
context.emit_event(EventType::ContactsChanged(None));
self.update_next(&context.sql, SecureJoinStep::Completed)
.await?;
Ok(())
}
/// Sends the requested handshake message to Alice.
///
/// This takes care of adding the required headers for the step.
async fn send_handshake_message(&self, context: &Context, step: BobHandshakeMsg) -> Result<()> {
send_handshake_message(context, &self.invite, self.chat_id, step).await
}
/// Returns whether we are waiting for a SecureJoin message from Alice, i.e. the protocol hasn't
/// yet completed.
pub(crate) fn in_progress(&self) -> bool {
!matches!(
self.next,
SecureJoinStep::Terminated | SecureJoinStep::Completed
)
}
}
/// Sends the requested handshake message to Alice.
///
/// Same as [`BobState::send_handshake_message`] but this variation allows us to send this
/// message before we create the state in [`BobState::start_protocol`].
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.
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
/// [`BobState::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",
},
}
}
}
/// The next message expected by [`BobState`] in the setup-contact/secure-join protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecureJoinStep {
/// Expecting the auth-required message.
///
/// This corresponds to the `vc-auth-required` or `vg-auth-required` message of step 3d.
AuthRequired,
/// Expecting the contact-confirm message.
///
/// This corresponds to the `vc-contact-confirm` or `vg-member-added` message of step
/// 6b.
ContactConfirm,
/// The protocol terminated because of an error.
///
/// The securejoin protocol terminated, this exists to ensure [`BobState`] can detect
/// when it earlier signalled that is should be terminated. It is an error to call with
/// this state.
Terminated,
/// The protocol completed.
///
/// This exists to ensure [`BobState`] can detect when it earlier signalled that it is
/// complete. It is an error to call with this state.
Completed,
}
impl SecureJoinStep {
/// Compares the legacy string representation of a step to a [`SecureJoinStep`] variant.
fn matches(&self, context: &Context, step: &str) -> bool {
match self {
Self::AuthRequired => step == "vc-auth-required" || step == "vg-auth-required",
Self::ContactConfirm => step == "vc-contact-confirm" || step == "vg-member-added",
SecureJoinStep::Terminated => {
warn!(context, "Terminated state for next securejoin step");
false
}
SecureJoinStep::Completed => {
warn!(context, "Completed state for next securejoin step");
false
}
}
}
}
impl rusqlite::types::ToSql for SecureJoinStep {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
let num = match &self {
SecureJoinStep::AuthRequired => 0,
SecureJoinStep::ContactConfirm => 1,
SecureJoinStep::Terminated => 2,
SecureJoinStep::Completed => 3,
};
let val = rusqlite::types::Value::Integer(num);
let out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out)
}
}
impl rusqlite::types::FromSql for SecureJoinStep {
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
i64::column_result(value).and_then(|val| match val {
0 => Ok(SecureJoinStep::AuthRequired),
1 => Ok(SecureJoinStep::ContactConfirm),
2 => Ok(SecureJoinStep::Terminated),
3 => Ok(SecureJoinStep::Completed),
_ => Err(rusqlite::types::FromSqlError::OutOfRange(val)),
})
}
}

View File

@@ -209,7 +209,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
.insert(alice_addr.to_string(), wrong_pubkey)
.unwrap();
let contact_bob = alice.add_or_lookup_contact(&bob).await;
let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id)
let handshake_msg = handle_securejoin_handshake(&alice, &mut msg, contact_bob.id)
.await
.unwrap();
assert_eq!(handshake_msg, HandshakeMessage::Ignore);
@@ -218,7 +218,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
msg.gossiped_keys
.insert(alice_addr.to_string(), alice_pubkey)
.unwrap();
let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id)
let handshake_msg = handle_securejoin_handshake(&alice, &mut msg, contact_bob.id)
.await
.unwrap();
assert_eq!(handshake_msg, HandshakeMessage::Ignore);
@@ -839,3 +839,110 @@ async fn test_shared_bobs_key() -> Result<()> {
assert_eq!(bob_ids.len(), 3);
Ok(())
}
/// Tests Bob joining two groups by scanning two QR codes
/// from the same Alice at the same time.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parallel_securejoin() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat1_id =
chat::create_group_chat(alice, ProtectionStatus::Protected, "First chat").await?;
let alice_chat2_id =
chat::create_group_chat(alice, ProtectionStatus::Protected, "Second chat").await?;
let qr1 = get_securejoin_qr(alice, Some(alice_chat1_id)).await?;
let qr2 = get_securejoin_qr(alice, Some(alice_chat2_id)).await?;
// Bob scans both QR codes.
let bob_chat1_id = join_securejoin(bob, &qr1).await?;
let sent_vg_request1 = bob.pop_sent_msg().await;
let bob_chat2_id = join_securejoin(bob, &qr2).await?;
let sent_vg_request2 = bob.pop_sent_msg().await;
// Alice receives two `vg-request` messages
// and sends back two `vg-auth-required` messages.
alice.recv_msg_trash(&sent_vg_request1).await;
let sent_vg_auth_required1 = alice.pop_sent_msg().await;
alice.recv_msg_trash(&sent_vg_request2).await;
let _sent_vg_auth_required2 = alice.pop_sent_msg().await;
// Bob receives first `vg-auth-required` message.
// Bob has two securejoin processes started,
// so he should send two `vg-request-with-auth` messages.
bob.recv_msg_trash(&sent_vg_auth_required1).await;
// Bob sends `vg-request-with-auth` messages.
let sent_vg_request_with_auth2 = bob.pop_sent_msg().await;
let sent_vg_request_with_auth1 = bob.pop_sent_msg().await;
// Alice receives both `vg-request-with-auth` messages.
alice.recv_msg_trash(&sent_vg_request_with_auth1).await;
let sent_vg_member_added1 = alice.pop_sent_msg().await;
let joined_chat_id1 = bob.recv_msg(&sent_vg_member_added1).await.chat_id;
assert_eq!(joined_chat_id1, bob_chat1_id);
alice.recv_msg_trash(&sent_vg_request_with_auth2).await;
let sent_vg_member_added2 = alice.pop_sent_msg().await;
let joined_chat_id2 = bob.recv_msg(&sent_vg_member_added2).await.chat_id;
assert_eq!(joined_chat_id2, bob_chat2_id);
Ok(())
}
/// Tests Bob scanning setup contact QR codes of Alice and Fiona
/// concurrently.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parallel_setup_contact() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
// Bob scans Alice's QR code,
// but Alice is offline and takes a while to respond.
let alice_qr = get_securejoin_qr(alice, None).await?;
join_securejoin(bob, &alice_qr).await?;
let sent_alice_vc_request = bob.pop_sent_msg().await;
// Bob scans Fiona's QR code while SecureJoin
// process with Alice is not finished.
let fiona_qr = get_securejoin_qr(fiona, None).await?;
join_securejoin(bob, &fiona_qr).await?;
let sent_fiona_vc_request = bob.pop_sent_msg().await;
fiona.recv_msg_trash(&sent_fiona_vc_request).await;
let sent_fiona_vc_auth_required = fiona.pop_sent_msg().await;
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
let sent_fiona_vc_request_with_auth = bob.pop_sent_msg().await;
fiona.recv_msg_trash(&sent_fiona_vc_request_with_auth).await;
let sent_fiona_vc_contact_confirm = fiona.pop_sent_msg().await;
bob.recv_msg_trash(&sent_fiona_vc_contact_confirm).await;
let bob_fiona_contact_id = bob.add_or_lookup_contact_id(fiona).await;
let bob_fiona_contact = Contact::get_by_id(bob, bob_fiona_contact_id).await.unwrap();
assert_eq!(bob_fiona_contact.is_verified(bob).await.unwrap(), true);
// Alice gets online and previously started SecureJoin process finishes.
alice.recv_msg_trash(&sent_alice_vc_request).await;
let sent_alice_vc_auth_required = alice.pop_sent_msg().await;
bob.recv_msg_trash(&sent_alice_vc_auth_required).await;
let sent_alice_vc_request_with_auth = bob.pop_sent_msg().await;
alice.recv_msg_trash(&sent_alice_vc_request_with_auth).await;
let sent_alice_vc_contact_confirm = alice.pop_sent_msg().await;
bob.recv_msg_trash(&sent_alice_vc_contact_confirm).await;
let bob_alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
let bob_alice_contact = Contact::get_by_id(bob, bob_alice_contact_id).await.unwrap();
assert_eq!(bob_alice_contact.is_verified(bob).await.unwrap(), true);
Ok(())
}