mirror of
https://github.com/chatmail/core.git
synced 2026-04-17 13:36:30 +03:00
* refactor: cleanup send_handshake_msg() - rename to send_alice_handshake_msg() as used by Alice only - remove dead code from Bob (Bob's code is at BobState::send_handshake_message() since some time) - take a contact_id and not a chat_id; this makes things less confusing when info-messages are put to the final group chat * always directly return chat-id from dc_join_securejoin() * take care not to create a group twice * adapt documentation * add info-msg on group invites; add inviter directly after creation * document existing 'joinqr' command in repl tool * do not create empty one-to-one chats for group-joins * refactor: cleanup fingerprint_equals_sender() - the function takes a contact_id directly now. before it consumes the first contact of a one-to-one chat - which may be easily confused with the group-chat in creation. moreover, the conversion contact_id -> chat_id -> contact_id is unneeded overhead. * show info-messages in destination chat for alice * fingerprint_equals_sender() returns Err on database failure * tweak documentation * clarify what an 'unfinished tasks' task is. * add regression test for create_for_contact_with_blocked() * rename Blocked::Manually to better fitting Blocked::Yes * tweak test_secure_join() and make sure, Alice and Bob have only on chat after a group-join
536 lines
21 KiB
Rust
536 lines
21 KiB
Rust
//! 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. Afterwards it must be stored in a mutex and the [`BobStateHandle`] should be
|
|
//! used to work with the state.
|
|
|
|
use anyhow::{bail, Error, Result};
|
|
use async_std::sync::MutexGuard;
|
|
|
|
use crate::chat::{self, ChatId};
|
|
use crate::constants::{Blocked, Viewtype};
|
|
use crate::contact::{Contact, Origin};
|
|
use crate::context::Context;
|
|
use crate::events::EventType;
|
|
use crate::headerdef::HeaderDef;
|
|
use crate::key::{DcKey, SignedPublicKey};
|
|
use crate::message::Message;
|
|
use crate::mimeparser::{MimeMessage, SystemMessage};
|
|
use crate::param::Param;
|
|
|
|
use super::qrinvite::QrInvite;
|
|
use super::{
|
|
encrypted_and_signed, fingerprint_equals_sender, mark_peer_as_verified, JoinError, SendMsgError,
|
|
};
|
|
|
|
/// 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.
|
|
///
|
|
/// Note that this is only ever returned by [`BobState::start_protocol`] and never by
|
|
/// [`BobState::handle_message`].
|
|
RequestSent,
|
|
/// Step 4 completed: (vc|vg)-request-with-auth message sent.
|
|
RequestWithAuthSent,
|
|
/// The protocol completed successfully.
|
|
Completed,
|
|
/// The protocol prematurely terminated with given reason.
|
|
Terminated(&'static str),
|
|
}
|
|
|
|
/// A handle to work with the [`BobState`] of Bob's securejoin protocol.
|
|
///
|
|
/// This handle can only be created for when an underlying [`BobState`] exists. It keeps
|
|
/// open a lock which guarantees unique access to the state and this struct must be dropped
|
|
/// to return the lock.
|
|
pub struct BobStateHandle<'a> {
|
|
guard: MutexGuard<'a, Option<BobState>>,
|
|
bobstate: BobState,
|
|
clear_state_on_drop: bool,
|
|
}
|
|
|
|
impl<'a> BobStateHandle<'a> {
|
|
/// Creates a new instance, upholding the guarantee that [`BobState`] must exist.
|
|
pub fn from_guard(mut guard: MutexGuard<'a, Option<BobState>>) -> Option<Self> {
|
|
guard.take().map(|bobstate| Self {
|
|
guard,
|
|
bobstate,
|
|
clear_state_on_drop: false,
|
|
})
|
|
}
|
|
|
|
/// Returns the [`ChatId`] of the group chat to join or the 1:1 chat with Alice.
|
|
pub async fn chat_id(&self, context: &Context) -> Result<ChatId> {
|
|
match self.bobstate.invite {
|
|
QrInvite::Group { ref grpid, .. } => {
|
|
if let Some((chat_id, _, _)) = chat::get_chat_id_by_grpid(context, &grpid).await? {
|
|
Ok(chat_id)
|
|
} else {
|
|
bail!("chat not found")
|
|
}
|
|
}
|
|
QrInvite::Contact { .. } => Ok(self.bobstate.chat_id),
|
|
}
|
|
}
|
|
|
|
/// Returns a reference to the [`QrInvite`] of the joiner process.
|
|
pub fn invite(&self) -> &QrInvite {
|
|
&self.bobstate.invite
|
|
}
|
|
|
|
/// Handles the given message for the securejoin handshake for Bob.
|
|
///
|
|
/// This proxies to [`BobState::handle_message`] and makes sure to clear the state when
|
|
/// the protocol state is terminal. It returns `Some` if the message successfully
|
|
/// advanced the state of the protocol state machine, `None` otherwise.
|
|
pub async fn handle_message(
|
|
&mut self,
|
|
context: &Context,
|
|
mime_message: &MimeMessage,
|
|
) -> Option<BobHandshakeStage> {
|
|
info!(context, "Handling securejoin message for BobStateHandle");
|
|
match self.bobstate.handle_message(context, mime_message).await {
|
|
Ok(Some(stage)) => {
|
|
if matches!(
|
|
stage,
|
|
BobHandshakeStage::Completed | BobHandshakeStage::Terminated(_)
|
|
) {
|
|
self.finish_protocol(context).await;
|
|
}
|
|
Some(stage)
|
|
}
|
|
Ok(None) => None,
|
|
Err(err) => {
|
|
warn!(
|
|
context,
|
|
"Error handling handshake message, aborting handshake: {}", err
|
|
);
|
|
self.finish_protocol(context).await;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Marks the bob handshake as finished.
|
|
///
|
|
/// This will clear the state on [`InnerContext::bob`] once this handle is dropped,
|
|
/// allowing a new handshake to be started from [`Bob`].
|
|
///
|
|
/// Note that the state is only cleared on Drop since otherwise the invariant that the
|
|
/// state is always consistent is violated. However the "ongoing" process is released
|
|
/// here a little bit earlier as this requires access to the Context, which we do not
|
|
/// have on Drop (Drop can not run asynchronous code). Stopping the "ongoing" process
|
|
/// will release [`securejoin`](super::securejoin) which in turn will finally free the
|
|
/// ongoing process using [`Context::free_ongoing`].
|
|
///
|
|
/// [`InnerContext::bob`]: crate::context::InnerContext::bob
|
|
/// [`Bob`]: super::Bob
|
|
async fn finish_protocol(&mut self, context: &Context) {
|
|
info!(context, "Finishing securejoin handshake protocol for Bob");
|
|
self.clear_state_on_drop = true;
|
|
if let QrInvite::Group { .. } = self.bobstate.invite {
|
|
context.stop_ongoing().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> Drop for BobStateHandle<'a> {
|
|
fn drop(&mut self) {
|
|
if self.clear_state_on_drop {
|
|
// The Option should already be empty because we take it out in the ctor,
|
|
// however the typesystem doesn't guarantee this so do it again anyway.
|
|
self.guard.take();
|
|
} else {
|
|
// Make sure to put back the BobState into the Option of the Mutex, it was taken
|
|
// out by the constructor.
|
|
self.guard.replace(self.bobstate.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The securejoin state kept in-memory while Bob is joining.
|
|
///
|
|
/// This is currently stored in [`Bob`] which is stored on the [`Context`], thus Bob can
|
|
/// only run one securejoin joiner protocol at a time.
|
|
///
|
|
/// This purposefully has nothing optional, the state is always fully valid. See
|
|
/// [`Bob::state`] to get access to this state.
|
|
///
|
|
/// # 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 {
|
|
/// 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`].
|
|
///
|
|
/// # Bob - the joiner's side
|
|
/// ## Step 2 in the "Setup Contact protocol", section 2.1 of countermitm 0.10.0
|
|
pub async fn start_protocol(
|
|
context: &Context,
|
|
invite: QrInvite,
|
|
) -> Result<(Self, BobHandshakeStage), JoinError> {
|
|
let chat_id =
|
|
ChatId::create_for_contact_with_blocked(context, invite.contact_id(), Blocked::Yes)
|
|
.await
|
|
.map_err(JoinError::UnknownContact)?;
|
|
if fingerprint_equals_sender(context, invite.fingerprint(), invite.contact_id()).await? {
|
|
// The scanned fingerprint matches Alice's key, we can proceed to step 4b.
|
|
info!(context, "Taking securejoin protocol shortcut");
|
|
let state = Self {
|
|
invite,
|
|
next: SecureJoinStep::ContactConfirm,
|
|
chat_id,
|
|
};
|
|
state
|
|
.send_handshake_message(context, BobHandshakeMsg::RequestWithAuth)
|
|
.await?;
|
|
Ok((state, BobHandshakeStage::RequestWithAuthSent))
|
|
} else {
|
|
let state = Self {
|
|
invite,
|
|
next: SecureJoinStep::AuthRequired,
|
|
chat_id,
|
|
};
|
|
state
|
|
.send_handshake_message(context, BobHandshakeMsg::Request)
|
|
.await?;
|
|
Ok((state, BobHandshakeStage::RequestSent))
|
|
}
|
|
}
|
|
|
|
/// Returns the [`QrInvite`] used to create this [`BobState`].
|
|
pub fn invite(&self) -> &QrInvite {
|
|
&self.invite
|
|
}
|
|
|
|
/// Handles the given message for 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::Completed`] or
|
|
/// [`BobHandshakeStage::Terminated`] are reached this [`BobState`] should be destroyed,
|
|
/// further calling it will just result in the messages being unused by this handshake.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Under normal operation this should never return an error, regardless of what kind of
|
|
/// message it is called with. Any errors therefore should be treated as fatal internal
|
|
/// errors and this entire [`BobState`] should be thrown away as the state machine can
|
|
/// no longer be considered consistent.
|
|
async fn handle_message(
|
|
&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.as_str()) {
|
|
info!(context, "{} message out of sync for BobState", step);
|
|
return Ok(None);
|
|
}
|
|
match step.as_str() {
|
|
"vg-auth-required" | "vc-auth-required" => {
|
|
self.step_auth_required(context, mime_message).await
|
|
}
|
|
"vg-member-added" | "vc-contact-confirm" => {
|
|
self.step_contact_confirm(context, mime_message).await
|
|
}
|
|
_ => {
|
|
warn!(context, "Invalid step for BobState: {}", step);
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if the message is expected according to the protocol.
|
|
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-auth-required* or *vg-auth-required* message.
|
|
///
|
|
/// # Bob - the joiner's side
|
|
/// ## Step 4 in the "Setup Contact protocol", section 2.1 of countermitm 0.10.0
|
|
async fn step_auth_required(
|
|
&mut self,
|
|
context: &Context,
|
|
mime_message: &MimeMessage,
|
|
) -> Result<Option<BobHandshakeStage>> {
|
|
info!(
|
|
context,
|
|
"Bob Step 4 - handling vc-auth-require/vg-auth-required message"
|
|
);
|
|
if !encrypted_and_signed(context, mime_message, Some(self.invite.fingerprint())) {
|
|
let reason = if mime_message.was_encrypted() {
|
|
"Valid signature missing"
|
|
} else {
|
|
"Required encryption missing"
|
|
};
|
|
self.next = SecureJoinStep::Terminated;
|
|
return Ok(Some(BobHandshakeStage::Terminated(reason)));
|
|
}
|
|
if !fingerprint_equals_sender(context, self.invite.fingerprint(), self.invite.contact_id())
|
|
.await?
|
|
{
|
|
self.next = SecureJoinStep::Terminated;
|
|
return Ok(Some(BobHandshakeStage::Terminated("Fingerprint mismatch")));
|
|
}
|
|
info!(context, "Fingerprint verified.",);
|
|
self.next = SecureJoinStep::ContactConfirm;
|
|
self.send_handshake_message(context, BobHandshakeMsg::RequestWithAuth)
|
|
.await?;
|
|
Ok(Some(BobHandshakeStage::RequestWithAuthSent))
|
|
}
|
|
|
|
/// 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
|
|
///
|
|
/// This deviates from the protocol by also sending a confirmation message in response
|
|
/// to the *vc-contact-confirm* message. This has no specific value to the protocol and
|
|
/// is only done out of symmerty with *vg-member-added* handling.
|
|
async fn step_contact_confirm(
|
|
&mut self,
|
|
context: &Context,
|
|
mime_message: &MimeMessage,
|
|
) -> Result<Option<BobHandshakeStage>> {
|
|
info!(
|
|
context,
|
|
"Bob Step 7 - handling vc-contact-confirm/vg-member-added message"
|
|
);
|
|
let vg_expect_encrypted = match self.invite {
|
|
QrInvite::Contact { .. } => {
|
|
// setup-contact is always encrypted
|
|
true
|
|
}
|
|
QrInvite::Group { ref grpid, .. } => {
|
|
// This is buggy, is_verified_group will always be
|
|
// false since the group is created by receive_imf for
|
|
// the very handshake message we're handling now. But
|
|
// only after we have returned. It does not impact
|
|
// the security invariants of secure-join however.
|
|
|
|
let is_verified_group = chat::get_chat_id_by_grpid(context, grpid)
|
|
.await?
|
|
.map_or(false, |(_chat_id, is_protected, _blocked)| is_protected);
|
|
// when joining a non-verified group
|
|
// the vg-member-added message may be unencrypted
|
|
// when not all group members have keys or prefer encryption.
|
|
// So only expect encryption if this is a verified group
|
|
is_verified_group
|
|
}
|
|
};
|
|
if vg_expect_encrypted
|
|
&& !encrypted_and_signed(context, mime_message, Some(self.invite.fingerprint()))
|
|
{
|
|
self.next = SecureJoinStep::Terminated;
|
|
return Ok(Some(BobHandshakeStage::Terminated(
|
|
"Contact confirm message not encrypted",
|
|
)));
|
|
}
|
|
mark_peer_as_verified(context, self.invite.fingerprint()).await?;
|
|
Contact::scaleup_origin_by_id(context, self.invite.contact_id(), Origin::SecurejoinJoined)
|
|
.await?;
|
|
context.emit_event(EventType::ContactsChanged(None));
|
|
|
|
if let QrInvite::Group { .. } = self.invite {
|
|
let member_added = mime_message
|
|
.get_header(HeaderDef::ChatGroupMemberAdded)
|
|
.map(|s| s.as_str())
|
|
.ok_or_else(|| Error::msg("Missing Chat-Group-Member-Added header"))?;
|
|
if !context.is_self_addr(member_added).await? {
|
|
info!(context, "Message belongs to a different handshake (scaled up contact anyway to allow creation of group).");
|
|
return Ok(None);
|
|
}
|
|
}
|
|
|
|
self.send_handshake_message(context, BobHandshakeMsg::ContactConfirmReceived)
|
|
.await
|
|
.map_err(|_| {
|
|
warn!(
|
|
context,
|
|
"Failed to send vc-contact-confirm-received/vg-member-added-received"
|
|
);
|
|
})
|
|
// This is not an error affecting the protocol outcome.
|
|
.ok();
|
|
|
|
self.next = SecureJoinStep::Completed;
|
|
Ok(Some(BobHandshakeStage::Completed))
|
|
}
|
|
|
|
/// 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<(), SendMsgError> {
|
|
let mut msg = Message {
|
|
viewtype: Viewtype::Text,
|
|
text: Some(step.body_text(&self.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(&self.invite));
|
|
|
|
match step {
|
|
BobHandshakeMsg::Request => {
|
|
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
|
|
msg.param.set(Param::Arg2, self.invite.invitenumber());
|
|
msg.param.set_int(Param::ForcePlaintext, 1);
|
|
}
|
|
BobHandshakeMsg::RequestWithAuth => {
|
|
// Sends the Secure-Join-Auth header in mimefactory.rs.
|
|
msg.param.set(Param::Arg2, self.invite.authcode());
|
|
msg.param.set_int(Param::GuaranteeE2ee, 1);
|
|
}
|
|
BobHandshakeMsg::ContactConfirmReceived => {
|
|
msg.param.set_int(Param::GuaranteeE2ee, 1);
|
|
}
|
|
};
|
|
|
|
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
|
|
let bob_fp = SignedPublicKey::load_self(context).await?.fingerprint();
|
|
msg.param.set(Param::Arg3, bob_fp.hex());
|
|
|
|
// Sends the grpid in the Secure-Join-Group header.
|
|
if let QrInvite::Group { ref grpid, .. } = self.invite {
|
|
msg.param.set(Param::Arg4, grpid);
|
|
}
|
|
|
|
chat::send_msg(context, self.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,
|
|
/// vc-contact-confirm-received or vg-member-added-received
|
|
ContactConfirmReceived,
|
|
}
|
|
|
|
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",
|
|
},
|
|
Self::ContactConfirmReceived => match invite {
|
|
QrInvite::Contact { .. } => "vc-contact-confirm-received",
|
|
QrInvite::Group { .. } => "vg-member-added-received",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The next message expected by [`BobState`] in the setup-contact/secure-join protocol.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
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, "Complted state for next securejoin step");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
}
|