Files
chatmail-core/src/securejoin/bobstate.rs

533 lines
20 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.
use anyhow::Result;
use rusqlite::Connection;
use super::qrinvite::QrInvite;
use super::{encrypted_and_signed, fingerprint_equals_sender, mark_peer_as_verified};
use crate::chat::{self, ChatId};
use crate::contact::{Contact, 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::sql::Sql;
/// 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),
}
/// 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 (stage, next) =
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");
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth)
.await?;
(
BobHandshakeStage::RequestWithAuthSent,
SecureJoinStep::ContactConfirm,
)
} else {
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::Request).await?;
(BobHandshakeStage::RequestSent, 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,
};
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 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.
pub(crate) 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.update_next(&context.sql, SecureJoinStep::Terminated)
.await?;
return Ok(Some(BobHandshakeStage::Terminated(reason)));
}
if !fingerprint_equals_sender(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))
}
/// 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 symmetry 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"
);
mark_peer_as_verified(
context,
self.invite.fingerprint().clone(),
mime_message.from.addr.to_string(),
)
.await?;
Contact::scaleup_origin_by_id(context, self.invite.contact_id(), Origin::SecurejoinJoined)
.await?;
context.emit_event(EventType::ContactsChanged(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.update_next(&context.sql, SecureJoinStep::Completed)
.await?;
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<()> {
send_handshake_message(context, &self.invite, self.chat_id, step).await
}
}
/// 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);
}
BobHandshakeMsg::ContactConfirmReceived => {
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?.fingerprint();
msg.param.set(Param::Arg3, bob_fp.hex());
// Sends the grpid in the Secure-Join-Group header.
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,
/// 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, 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)),
})
}
}