mirror of
https://github.com/chatmail/core.git
synced 2026-04-17 13:36:30 +03:00
1404 lines
54 KiB
Rust
1404 lines
54 KiB
Rust
//! Verified contact protocol implementation as [specified by countermitm project](https://countermitm.readthedocs.io/en/stable/new.html#setup-contact-protocol).
|
|
|
|
use std::convert::TryFrom;
|
|
|
|
use anyhow::{bail, Context as _, Error, Result};
|
|
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
|
|
|
use crate::aheader::EncryptPreference;
|
|
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
|
|
use crate::config::Config;
|
|
use crate::constants::Blocked;
|
|
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::{load_self_public_key, DcKey, Fingerprint};
|
|
use crate::message::{Message, Viewtype};
|
|
use crate::mimeparser::{MimeMessage, SystemMessage};
|
|
use crate::param::Param;
|
|
use crate::peerstate::{Peerstate, PeerstateKeyType};
|
|
use crate::qr::check_qr;
|
|
use crate::stock_str;
|
|
use crate::token;
|
|
use crate::tools::time;
|
|
|
|
mod bob;
|
|
mod bobstate;
|
|
mod qrinvite;
|
|
|
|
use bobstate::BobState;
|
|
use qrinvite::QrInvite;
|
|
|
|
use crate::token::Namespace;
|
|
|
|
/// Set of characters to percent-encode in email addresses and names.
|
|
pub const NON_ALPHANUMERIC_WITHOUT_DOT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'.');
|
|
|
|
fn inviter_progress(context: &Context, contact_id: ContactId, progress: usize) {
|
|
debug_assert!(
|
|
progress <= 1000,
|
|
"value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success"
|
|
);
|
|
context.emit_event(EventType::SecurejoinInviterProgress {
|
|
contact_id,
|
|
progress,
|
|
});
|
|
}
|
|
|
|
/// Generates a Secure Join QR code.
|
|
///
|
|
/// With `group` set to `None` this generates a setup-contact QR code, with `group` set to a
|
|
/// [`ChatId`] generates a join-group QR code for the given chat.
|
|
pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Result<String> {
|
|
/*=======================================================
|
|
==== Alice - the inviter side ====
|
|
==== Step 1 in "Setup verified contact" protocol ====
|
|
=======================================================*/
|
|
|
|
ensure_secret_key_exists(context).await.ok();
|
|
|
|
// invitenumber will be used to allow starting the handshake,
|
|
// auth will be used to verify the fingerprint
|
|
let sync_token = token::lookup(context, Namespace::InviteNumber, group)
|
|
.await?
|
|
.is_none();
|
|
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, group).await;
|
|
let auth = token::lookup_or_new(context, Namespace::Auth, group).await;
|
|
let self_addr = context.get_primary_self_addr().await?;
|
|
let self_name = context
|
|
.get_config(Config::Displayname)
|
|
.await?
|
|
.unwrap_or_default();
|
|
|
|
let fingerprint: Fingerprint = match get_self_fingerprint(context).await {
|
|
Some(fp) => fp,
|
|
None => {
|
|
bail!("No fingerprint, cannot generate QR code.");
|
|
}
|
|
};
|
|
|
|
let self_addr_urlencoded =
|
|
utf8_percent_encode(&self_addr, NON_ALPHANUMERIC_WITHOUT_DOT).to_string();
|
|
let self_name_urlencoded =
|
|
utf8_percent_encode(&self_name, NON_ALPHANUMERIC_WITHOUT_DOT).to_string();
|
|
|
|
let qr = if let Some(group) = group {
|
|
// parameters used: a=g=x=i=s=
|
|
let chat = Chat::load_from_db(context, group).await?;
|
|
if chat.grpid.is_empty() {
|
|
bail!(
|
|
"can't generate securejoin QR code for ad-hoc group {}",
|
|
group
|
|
);
|
|
}
|
|
let group_name = chat.get_name();
|
|
let group_name_urlencoded = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string();
|
|
if sync_token {
|
|
context.sync_qr_code_tokens(Some(chat.id)).await?;
|
|
}
|
|
format!(
|
|
"OPENPGP4FPR:{}#a={}&g={}&x={}&i={}&s={}",
|
|
fingerprint.hex(),
|
|
self_addr_urlencoded,
|
|
&group_name_urlencoded,
|
|
&chat.grpid,
|
|
&invitenumber,
|
|
&auth,
|
|
)
|
|
} else {
|
|
// parameters used: a=n=i=s=
|
|
if sync_token {
|
|
context.sync_qr_code_tokens(None).await?;
|
|
}
|
|
format!(
|
|
"OPENPGP4FPR:{}#a={}&n={}&i={}&s={}",
|
|
fingerprint.hex(),
|
|
self_addr_urlencoded,
|
|
self_name_urlencoded,
|
|
&invitenumber,
|
|
&auth,
|
|
)
|
|
};
|
|
|
|
info!(context, "Generated QR code: {}", qr);
|
|
|
|
Ok(qr)
|
|
}
|
|
|
|
async fn get_self_fingerprint(context: &Context) -> Option<Fingerprint> {
|
|
match load_self_public_key(context).await {
|
|
Ok(key) => Some(key.fingerprint()),
|
|
Err(_) => {
|
|
warn!(context, "get_self_fingerprint(): failed to load key");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
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
|
|
})
|
|
}
|
|
|
|
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)?;
|
|
|
|
bob::start_protocol(context, invite).await
|
|
}
|
|
|
|
/// Send handshake message from Alice's device;
|
|
/// Bob's handshake messages are sent in `BobState::send_handshake_message()`.
|
|
async fn send_alice_handshake_msg(
|
|
context: &Context,
|
|
contact_id: ContactId,
|
|
step: &str,
|
|
fingerprint: Option<Fingerprint>,
|
|
) -> 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);
|
|
if let Some(fp) = fingerprint {
|
|
msg.param.set(Param::Arg3, fp.hex());
|
|
}
|
|
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)
|
|
}
|
|
|
|
async fn fingerprint_equals_sender(
|
|
context: &Context,
|
|
fingerprint: &Fingerprint,
|
|
contact_id: ContactId,
|
|
) -> Result<bool> {
|
|
let contact = Contact::get_by_id(context, contact_id).await?;
|
|
let peerstate = match Peerstate::from_addr(context, contact.get_addr()).await {
|
|
Ok(peerstate) => peerstate,
|
|
Err(err) => {
|
|
warn!(
|
|
context,
|
|
"Failed to sender peerstate for {}: {}",
|
|
contact.get_addr(),
|
|
err
|
|
);
|
|
return Ok(false);
|
|
}
|
|
};
|
|
|
|
if let Some(peerstate) = peerstate {
|
|
if peerstate
|
|
.public_key_fingerprint
|
|
.as_ref()
|
|
.filter(|&fp| fp == fingerprint)
|
|
.is_some()
|
|
{
|
|
return Ok(true);
|
|
}
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
/// What to do with a Secure-Join handshake message after it was handled.
|
|
///
|
|
/// This status is returned to [`receive_imf`] which will use it to decide what to do
|
|
/// next with this incoming setup-contact/secure-join handshake message.
|
|
///
|
|
/// [`receive_imf`]: crate::receive_imf::receive_imf
|
|
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 ([`BobState`]).
|
|
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,
|
|
}
|
|
|
|
/// 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.
|
|
#[allow(clippy::indexing_slicing)]
|
|
pub(crate) async fn handle_securejoin_handshake(
|
|
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 = mime_message
|
|
.get_header(HeaderDef::SecureJoin)
|
|
.context("Not a Secure-Join message")?;
|
|
|
|
info!(
|
|
context,
|
|
">>>>>>>>>>>>>>>>>>>>>>>>> secure-join message \'{}\' received", step,
|
|
);
|
|
|
|
let join_vg = step.starts_with("vg-");
|
|
|
|
match step.as_str() {
|
|
"vg-request" | "vc-request" => {
|
|
/*=======================================================
|
|
==== 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
|
|
let invitenumber = match mime_message.get_header(HeaderDef::SecureJoinInvitenumber) {
|
|
Some(n) => n,
|
|
None => {
|
|
warn!(context, "Secure-join denied (invitenumber missing)");
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
};
|
|
if !token::exists(context, token::Namespace::InviteNumber, invitenumber).await {
|
|
warn!(context, "Secure-join denied (bad invitenumber).");
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
info!(context, "Secure-join requested.",);
|
|
|
|
inviter_progress(context, contact_id, 300);
|
|
|
|
// for setup-contact, make Alice's one-to-one chat with Bob visible
|
|
// (secure-join-information are shown in the group chat)
|
|
if !join_vg {
|
|
ChatId::create_for_contact(context, contact_id).await?;
|
|
}
|
|
|
|
// Alice -> Bob
|
|
send_alice_handshake_msg(
|
|
context,
|
|
contact_id,
|
|
&format!("{}-auth-required", &step[..2]),
|
|
None,
|
|
)
|
|
.await
|
|
.context("failed sending auth-required handshake message")?;
|
|
Ok(HandshakeMessage::Done)
|
|
}
|
|
"vg-auth-required" | "vc-auth-required" => {
|
|
/*========================================================
|
|
==== Bob - the joiner's side =====
|
|
==== Step 4 in "Setup verified contact" protocol =====
|
|
========================================================*/
|
|
bob::handle_auth_required(context, mime_message).await
|
|
}
|
|
"vg-request-with-auth" | "vc-request-with-auth" => {
|
|
/*==========================================================
|
|
==== 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 fingerprint: Fingerprint =
|
|
match mime_message.get_header(HeaderDef::SecureJoinFingerprint) {
|
|
Some(fp) => fp.parse()?,
|
|
None => {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
"Fingerprint not provided.",
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
};
|
|
if !encrypted_and_signed(context, mime_message, Some(&fingerprint)) {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
"Auth not encrypted.",
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
if !fingerprint_equals_sender(context, &fingerprint, contact_id).await? {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
"Fingerprint mismatch on inviter-side.",
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
info!(context, "Fingerprint verified.",);
|
|
// verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code
|
|
let auth_0 = match mime_message.get_header(HeaderDef::SecureJoinAuth) {
|
|
Some(auth) => auth,
|
|
None => {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
"Auth not provided.",
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
};
|
|
if !token::exists(context, token::Namespace::Auth, auth_0).await {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
"Auth invalid.",
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
let contact_addr = Contact::get_by_id(context, contact_id)
|
|
.await?
|
|
.get_addr()
|
|
.to_owned();
|
|
let fingerprint_found =
|
|
mark_peer_as_verified(context, fingerprint.clone(), contact_addr).await?;
|
|
if !fingerprint_found {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
"Fingerprint mismatch on inviter-side.",
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
Contact::scaleup_origin_by_id(context, contact_id, Origin::SecurejoinInvited).await?;
|
|
info!(context, "Auth verified.",);
|
|
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
|
|
inviter_progress(context, contact_id, 600);
|
|
if join_vg {
|
|
// the vg-member-added message is special:
|
|
// this is a normal Chat-Group-Member-Added message
|
|
// with an additional Secure-Join header
|
|
let field_grpid = match mime_message.get_header(HeaderDef::SecureJoinGroup) {
|
|
Some(s) => s.as_str(),
|
|
None => {
|
|
warn!(context, "Missing Secure-Join-Group header");
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
};
|
|
match chat::get_chat_id_by_grpid(context, field_grpid).await? {
|
|
Some((group_chat_id, _, _)) => {
|
|
secure_connection_established(context, contact_id, group_chat_id).await?;
|
|
chat::add_contact_to_chat_ex(context, group_chat_id, contact_id, true)
|
|
.await?;
|
|
}
|
|
None => bail!("Chat {} not found", &field_grpid),
|
|
}
|
|
inviter_progress(context, contact_id, 800);
|
|
inviter_progress(context, contact_id, 1000);
|
|
} else {
|
|
// Alice -> Bob
|
|
secure_connection_established(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
)
|
|
.await?;
|
|
send_alice_handshake_msg(
|
|
context,
|
|
contact_id,
|
|
"vc-contact-confirm",
|
|
Some(fingerprint),
|
|
)
|
|
.await
|
|
.context("failed sending vc-contact-confirm message")?;
|
|
|
|
inviter_progress(context, contact_id, 1000);
|
|
}
|
|
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 ====
|
|
=======================================================*/
|
|
"vc-contact-confirm" => match BobState::from_db(&context.sql).await? {
|
|
Some(bobstate) => bob::handle_contact_confirm(context, bobstate, mime_message).await,
|
|
None => Ok(HandshakeMessage::Ignore),
|
|
},
|
|
|
|
"vg-member-added" => {
|
|
let Some(member_added) = mime_message
|
|
.get_header(HeaderDef::ChatGroupMemberAdded)
|
|
.map(|s| s.as_str())
|
|
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);
|
|
}
|
|
match BobState::from_db(&context.sql).await? {
|
|
Some(bobstate) => {
|
|
bob::handle_contact_confirm(context, bobstate, mime_message).await
|
|
}
|
|
None => Ok(HandshakeMessage::Propagate),
|
|
}
|
|
}
|
|
|
|
"vg-member-added-received" | "vc-contact-confirm-received" => {
|
|
/*==========================================================
|
|
==== Alice - the inviter side ====
|
|
==== Step 8 in "Out-of-band verified groups" protocol ====
|
|
==========================================================*/
|
|
|
|
Ok(HandshakeMessage::Done) // "Done" deletes the message
|
|
}
|
|
_ => {
|
|
warn!(context, "invalid step: {}", step);
|
|
Ok(HandshakeMessage::Ignore)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// observe_securejoin_on_other_device() must be called when a self-sent securejoin message is seen.
|
|
///
|
|
/// in a multi-device-setup, there may be other devices that "see" the handshake messages.
|
|
/// if the seen messages seen are self-sent messages encrypted+signed correctly with our key,
|
|
/// we can make some conclusions of it:
|
|
///
|
|
/// - if we see the self-sent-message vg-member-added/vc-contact-confirm,
|
|
/// we know that we're an inviter-observer.
|
|
/// The inviting device has marked a peer as verified on vg-request-with-auth/vc-request-with-auth
|
|
/// before sending vg-member-added/vc-contact-confirm - so, if we observe vg-member-added/vc-contact-confirm,
|
|
/// we can mark the peer as verified as well.
|
|
///
|
|
/// - if we see the self-sent-message vg-member-added-received
|
|
/// we know that we're an joiner-observer.
|
|
/// the joining device has marked the peer as verified on vg-member-added/vc-contact-confirm
|
|
/// before sending vg-member-added-received - so, if we observe vg-member-added-received,
|
|
/// we can mark the peer as verified as well.
|
|
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 = mime_message
|
|
.get_header(HeaderDef::SecureJoin)
|
|
.context("Not a Secure-Join message")?;
|
|
info!(context, "observing secure-join message \'{}\'", step);
|
|
|
|
match step.as_str() {
|
|
"vg-request-with-auth"
|
|
| "vc-request-with-auth"
|
|
| "vg-member-added"
|
|
| "vc-contact-confirm"
|
|
| "vg-member-added-received"
|
|
| "vc-contact-confirm-received" => {
|
|
if !encrypted_and_signed(
|
|
context,
|
|
mime_message,
|
|
get_self_fingerprint(context).await.as_ref(),
|
|
) {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
"Message not encrypted correctly.",
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
let addr = Contact::get_by_id(context, contact_id)
|
|
.await?
|
|
.get_addr()
|
|
.to_lowercase();
|
|
if mime_message.gossiped_addr.contains(&addr) {
|
|
let mut peerstate = match Peerstate::from_addr(context, &addr).await? {
|
|
Some(p) => p,
|
|
None => {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
&format!("No peerstate in db for '{}' at step {}", &addr, step),
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
};
|
|
let fingerprint = match peerstate.gossip_key_fingerprint.clone() {
|
|
Some(fp) => fp,
|
|
None => {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
&format!(
|
|
"No gossip key fingerprint in db for '{}' at step {}",
|
|
&addr, step,
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
};
|
|
if !peerstate.is_using_verified_key() {
|
|
// The `if` is purely for performance:
|
|
// If the verified key was used already, then the 1:1 chat was already protected.
|
|
ChatId::set_protection_for_contact(context, contact_id).await?;
|
|
}
|
|
peerstate.set_verified(PeerstateKeyType::GossipKey, fingerprint, addr)?;
|
|
peerstate.prefer_encrypt = EncryptPreference::Mutual;
|
|
peerstate.save_to_db(&context.sql).await.unwrap_or_default();
|
|
} else if let Some(fingerprint) =
|
|
mime_message.get_header(HeaderDef::SecureJoinFingerprint)
|
|
{
|
|
// FIXME: Old versions of DC send this header instead of gossips. Remove this
|
|
// eventually.
|
|
let fingerprint = fingerprint.parse()?;
|
|
let fingerprint_found = mark_peer_as_verified(
|
|
context,
|
|
fingerprint,
|
|
Contact::get_by_id(context, contact_id)
|
|
.await?
|
|
.get_addr()
|
|
.to_owned(),
|
|
)
|
|
.await?;
|
|
if !fingerprint_found {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
format!("Fingerprint mismatch on observing {step}.").as_ref(),
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
} else {
|
|
could_not_establish_secure_connection(
|
|
context,
|
|
contact_id,
|
|
info_chat_id(context, contact_id).await?,
|
|
&format!(
|
|
"No gossip header for '{}' at step {}, please update Delta Chat on all \
|
|
your devices.",
|
|
&addr, step,
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(HandshakeMessage::Ignore);
|
|
}
|
|
if step.as_str() == "vg-member-added" {
|
|
inviter_progress(context, contact_id, 800);
|
|
}
|
|
if step.as_str() == "vg-member-added" || step.as_str() == "vc-contact-confirm" {
|
|
inviter_progress(context, contact_id, 1000);
|
|
}
|
|
if step.as_str() == "vg-request-with-auth" || step.as_str() == "vc-request-with-auth" {
|
|
// 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?;
|
|
}
|
|
Ok(if step.as_str() == "vg-member-added" {
|
|
HandshakeMessage::Propagate
|
|
} else {
|
|
HandshakeMessage::Ignore
|
|
})
|
|
}
|
|
_ => Ok(HandshakeMessage::Ignore),
|
|
}
|
|
}
|
|
|
|
async fn secure_connection_established(
|
|
context: &Context,
|
|
contact_id: ContactId,
|
|
chat_id: ChatId,
|
|
) -> Result<()> {
|
|
let contact = Contact::get_by_id(context, contact_id).await?;
|
|
let msg = stock_str::contact_verified(context, &contact).await;
|
|
chat::add_info_msg(context, chat_id, &msg, time()).await?;
|
|
if context
|
|
.get_config_bool(Config::VerifiedOneOnOneChats)
|
|
.await?
|
|
{
|
|
let private_chat_id = ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Yes)
|
|
.await?
|
|
.id;
|
|
private_chat_id
|
|
.set_protection(
|
|
context,
|
|
ProtectionStatus::Protected,
|
|
time(),
|
|
Some(contact_id),
|
|
)
|
|
.await?;
|
|
}
|
|
context.emit_event(EventType::ChatModified(chat_id));
|
|
Ok(())
|
|
}
|
|
|
|
async fn could_not_establish_secure_connection(
|
|
context: &Context,
|
|
contact_id: ContactId,
|
|
chat_id: ChatId,
|
|
details: &str,
|
|
) -> Result<()> {
|
|
let contact = Contact::get_by_id(context, contact_id).await?;
|
|
let msg = stock_str::contact_not_verified(context, &contact).await;
|
|
chat::add_info_msg(context, chat_id, &msg, time()).await?;
|
|
warn!(
|
|
context,
|
|
"StockMessage::ContactNotVerified posted to 1:1 chat ({})", details
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Tries to mark peer with provided key fingerprint as verified.
|
|
///
|
|
/// Returns true if such key was found, false otherwise.
|
|
async fn mark_peer_as_verified(
|
|
context: &Context,
|
|
fingerprint: Fingerprint,
|
|
verifier: String,
|
|
) -> Result<bool> {
|
|
let Some(ref mut peerstate) = Peerstate::from_fingerprint(context, &fingerprint).await? else {
|
|
return Ok(false);
|
|
};
|
|
peerstate.set_verified(PeerstateKeyType::PublicKey, fingerprint, verifier)?;
|
|
peerstate.prefer_encrypt = EncryptPreference::Mutual;
|
|
peerstate.save_to_db(&context.sql).await.unwrap_or_default();
|
|
Ok(true)
|
|
}
|
|
|
|
/* ******************************************************************************
|
|
* Tools: Misc.
|
|
******************************************************************************/
|
|
|
|
fn encrypted_and_signed(
|
|
context: &Context,
|
|
mimeparser: &MimeMessage,
|
|
expected_fingerprint: Option<&Fingerprint>,
|
|
) -> bool {
|
|
if !mimeparser.was_encrypted() {
|
|
warn!(context, "Message not encrypted.",);
|
|
false
|
|
} else if let Some(expected_fingerprint) = expected_fingerprint {
|
|
if !mimeparser.signatures.contains(expected_fingerprint) {
|
|
warn!(
|
|
context,
|
|
"Message does not match expected fingerprint {}.", expected_fingerprint,
|
|
);
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
} else {
|
|
warn!(context, "Fingerprint for comparison missing.");
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::chat;
|
|
use crate::chat::ProtectionStatus;
|
|
use crate::chatlist::Chatlist;
|
|
use crate::constants::Chattype;
|
|
use crate::contact::ContactAddress;
|
|
use crate::contact::VerifiedStatus;
|
|
use crate::peerstate::Peerstate;
|
|
use crate::receive_imf::receive_imf;
|
|
use crate::stock_str::chat_protection_enabled;
|
|
use crate::test_utils::get_chat_msg;
|
|
use crate::test_utils::{TestContext, TestContextManager};
|
|
use crate::tools::EmailAddress;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_setup_contact() {
|
|
let mut tcm = TestContextManager::new();
|
|
let alice = tcm.alice().await;
|
|
let bob = tcm.bob().await;
|
|
alice
|
|
.set_config(Config::VerifiedOneOnOneChats, Some("1"))
|
|
.await
|
|
.unwrap();
|
|
bob.set_config(Config::VerifiedOneOnOneChats, Some("1"))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
Chatlist::try_load(&alice, 0, None, None)
|
|
.await
|
|
.unwrap()
|
|
.len(),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
|
|
0
|
|
);
|
|
|
|
// Step 1: Generate QR-code, ChatId(0) indicates setup-contact
|
|
let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap();
|
|
|
|
// Step 2: Bob scans QR-code, sends vc-request
|
|
join_securejoin(&bob.ctx, &qr).await.unwrap();
|
|
assert_eq!(
|
|
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
|
|
1
|
|
);
|
|
|
|
let sent = bob.pop_sent_msg().await;
|
|
assert_eq!(
|
|
sent.recipient(),
|
|
EmailAddress::new("alice@example.org").unwrap()
|
|
);
|
|
let msg = alice.parse_msg(&sent).await;
|
|
assert!(!msg.was_encrypted());
|
|
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-request");
|
|
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
|
|
|
|
// Step 3: Alice receives vc-request, sends vc-auth-required
|
|
alice.recv_msg(&sent).await;
|
|
assert_eq!(
|
|
Chatlist::try_load(&alice, 0, None, None)
|
|
.await
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
|
|
let sent = alice.pop_sent_msg().await;
|
|
let msg = bob.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vc-auth-required"
|
|
);
|
|
|
|
// Step 4: Bob receives vc-auth-required, sends vc-request-with-auth
|
|
bob.recv_msg(&sent).await;
|
|
|
|
// Check Bob emitted the JoinerProgress event.
|
|
let event = bob
|
|
.evtracker
|
|
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
|
|
.await;
|
|
match event {
|
|
EventType::SecurejoinJoinerProgress {
|
|
contact_id,
|
|
progress,
|
|
} => {
|
|
let alice_contact_id =
|
|
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
|
|
.await
|
|
.expect("Error looking up contact")
|
|
.expect("Contact not found");
|
|
assert_eq!(contact_id, alice_contact_id);
|
|
assert_eq!(progress, 400);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
// Check Bob sent the right message.
|
|
let sent = bob.pop_sent_msg().await;
|
|
let msg = alice.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vc-request-with-auth"
|
|
);
|
|
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
|
|
let bob_fp = load_self_public_key(&bob.ctx).await.unwrap().fingerprint();
|
|
assert_eq!(
|
|
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
|
|
bob_fp.hex()
|
|
);
|
|
|
|
// Alice should not yet have Bob verified
|
|
let contact_bob_id =
|
|
Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown)
|
|
.await
|
|
.expect("Error looking up contact")
|
|
.expect("Contact not found");
|
|
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
contact_bob.is_verified(&alice.ctx).await.unwrap(),
|
|
VerifiedStatus::Unverified
|
|
);
|
|
|
|
// Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm
|
|
alice.recv_msg(&sent).await;
|
|
assert_eq!(
|
|
contact_bob.is_verified(&alice.ctx).await.unwrap(),
|
|
VerifiedStatus::BidirectVerified
|
|
);
|
|
|
|
// exactly one one-to-one chat should be visible for both now
|
|
// (check this before calling alice.create_chat() explicitly below)
|
|
assert_eq!(
|
|
Chatlist::try_load(&alice, 0, None, None)
|
|
.await
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
|
|
1
|
|
);
|
|
|
|
// Check Alice got the verified message in her 1:1 chat.
|
|
{
|
|
let chat = alice.create_chat(&bob).await;
|
|
let msg_ids: Vec<_> = chat::get_chat_msgs(&alice.ctx, chat.get_id())
|
|
.await
|
|
.unwrap()
|
|
.into_iter()
|
|
.filter_map(|item| match item {
|
|
chat::ChatItem::Message { msg_id } => Some(msg_id),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(msg_ids.len(), 2);
|
|
|
|
let msg0 = Message::load_from_db(&alice.ctx, msg_ids[0]).await.unwrap();
|
|
assert!(msg0.is_info());
|
|
assert!(msg0.get_text().contains("bob@example.net verified"));
|
|
|
|
let msg1 = Message::load_from_db(&alice.ctx, msg_ids[1]).await.unwrap();
|
|
assert!(msg1.is_info());
|
|
let expected_text = chat_protection_enabled(&alice).await;
|
|
assert_eq!(msg1.get_text(), expected_text);
|
|
}
|
|
|
|
// Check Alice sent the right message to Bob.
|
|
let sent = alice.pop_sent_msg().await;
|
|
let msg = bob.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vc-contact-confirm"
|
|
);
|
|
|
|
// Bob should not yet have Alice verified
|
|
let contact_alice_id =
|
|
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
|
|
.await
|
|
.expect("Error looking up contact")
|
|
.expect("Contact not found");
|
|
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
contact_bob.is_verified(&bob.ctx).await.unwrap(),
|
|
VerifiedStatus::Unverified
|
|
);
|
|
|
|
// Step 7: Bob receives vc-contact-confirm, sends vc-contact-confirm-received
|
|
bob.recv_msg(&sent).await;
|
|
assert_eq!(
|
|
contact_alice.is_verified(&bob.ctx).await.unwrap(),
|
|
VerifiedStatus::BidirectVerified
|
|
);
|
|
|
|
// Check Bob got the verified message in his 1:1 chat.
|
|
{
|
|
let chat = bob.create_chat(&alice).await;
|
|
let msg_ids: Vec<_> = chat::get_chat_msgs(&bob.ctx, chat.get_id())
|
|
.await
|
|
.unwrap()
|
|
.into_iter()
|
|
.filter_map(|item| match item {
|
|
chat::ChatItem::Message { msg_id } => Some(msg_id),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
let msg0 = Message::load_from_db(&bob.ctx, msg_ids[0]).await.unwrap();
|
|
assert!(msg0.is_info());
|
|
assert!(msg0.get_text().contains("alice@example.org verified"));
|
|
|
|
let msg1 = Message::load_from_db(&bob.ctx, msg_ids[1]).await.unwrap();
|
|
assert!(msg1.is_info());
|
|
let expected_text = chat_protection_enabled(&bob).await;
|
|
assert_eq!(msg1.get_text(), expected_text);
|
|
}
|
|
|
|
// Check Bob sent the final message
|
|
let sent = bob.pop_sent_msg().await;
|
|
let msg = alice.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vc-contact-confirm-received"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_setup_contact_bad_qr() {
|
|
let bob = TestContext::new_bob().await;
|
|
let ret = join_securejoin(&bob.ctx, "not a qr code").await;
|
|
assert!(ret.is_err());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_setup_contact_bob_knows_alice() -> Result<()> {
|
|
let mut tcm = TestContextManager::new();
|
|
let alice = tcm.alice().await;
|
|
let bob = tcm.bob().await;
|
|
|
|
// Ensure Bob knows Alice_FP
|
|
let alice_pubkey = load_self_public_key(&alice.ctx).await?;
|
|
let peerstate = Peerstate {
|
|
addr: "alice@example.org".into(),
|
|
last_seen: 10,
|
|
last_seen_autocrypt: 10,
|
|
prefer_encrypt: EncryptPreference::Mutual,
|
|
public_key: Some(alice_pubkey.clone()),
|
|
public_key_fingerprint: Some(alice_pubkey.fingerprint()),
|
|
gossip_key: Some(alice_pubkey.clone()),
|
|
gossip_timestamp: 10,
|
|
gossip_key_fingerprint: Some(alice_pubkey.fingerprint()),
|
|
verified_key: None,
|
|
verified_key_fingerprint: None,
|
|
verifier: None,
|
|
secondary_verified_key: None,
|
|
secondary_verified_key_fingerprint: None,
|
|
secondary_verifier: None,
|
|
fingerprint_changed: false,
|
|
};
|
|
peerstate.save_to_db(&bob.ctx.sql).await?;
|
|
|
|
// Step 1: Generate QR-code, ChatId(0) indicates setup-contact
|
|
let qr = get_securejoin_qr(&alice.ctx, None).await?;
|
|
|
|
// Step 2+4: Bob scans QR-code, sends vc-request-with-auth, skipping vc-request
|
|
join_securejoin(&bob.ctx, &qr).await.unwrap();
|
|
|
|
// Check Bob emitted the JoinerProgress event.
|
|
let event = bob
|
|
.evtracker
|
|
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
|
|
.await;
|
|
match event {
|
|
EventType::SecurejoinJoinerProgress {
|
|
contact_id,
|
|
progress,
|
|
} => {
|
|
let alice_contact_id =
|
|
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
|
|
.await
|
|
.expect("Error looking up contact")
|
|
.expect("Contact not found");
|
|
assert_eq!(contact_id, alice_contact_id);
|
|
assert_eq!(progress, 400);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
// Check Bob sent the right handshake message.
|
|
let sent = bob.pop_sent_msg().await;
|
|
let msg = alice.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vc-request-with-auth"
|
|
);
|
|
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
|
|
let bob_fp = load_self_public_key(&bob.ctx).await?.fingerprint();
|
|
assert_eq!(
|
|
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
|
|
bob_fp.hex()
|
|
);
|
|
|
|
// Alice should not yet have Bob verified
|
|
let (contact_bob_id, _modified) = Contact::add_or_lookup(
|
|
&alice.ctx,
|
|
"Bob",
|
|
ContactAddress::new("bob@example.net")?,
|
|
Origin::ManuallyCreated,
|
|
)
|
|
.await?;
|
|
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?;
|
|
assert_eq!(
|
|
contact_bob.is_verified(&alice.ctx).await?,
|
|
VerifiedStatus::Unverified
|
|
);
|
|
|
|
// Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm
|
|
alice.recv_msg(&sent).await;
|
|
assert_eq!(
|
|
contact_bob.is_verified(&alice.ctx).await?,
|
|
VerifiedStatus::BidirectVerified
|
|
);
|
|
|
|
let sent = alice.pop_sent_msg().await;
|
|
let msg = bob.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vc-contact-confirm"
|
|
);
|
|
|
|
// Bob should not yet have Alice verified
|
|
let contact_alice_id =
|
|
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
|
|
.await
|
|
.expect("Error looking up contact")
|
|
.expect("Contact not found");
|
|
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?;
|
|
assert_eq!(
|
|
contact_bob.is_verified(&bob.ctx).await?,
|
|
VerifiedStatus::Unverified
|
|
);
|
|
|
|
// Step 7: Bob receives vc-contact-confirm, sends vc-contact-confirm-received
|
|
bob.recv_msg(&sent).await;
|
|
assert_eq!(
|
|
contact_alice.is_verified(&bob.ctx).await?,
|
|
VerifiedStatus::BidirectVerified
|
|
);
|
|
|
|
let sent = bob.pop_sent_msg().await;
|
|
let msg = alice.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vc-contact-confirm-received"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_setup_contact_concurrent_calls() -> Result<()> {
|
|
let mut tcm = TestContextManager::new();
|
|
let alice = tcm.alice().await;
|
|
let bob = tcm.bob().await;
|
|
|
|
// do a scan that is not working as claire is never responding
|
|
let qr_stale = "OPENPGP4FPR:1234567890123456789012345678901234567890#a=claire%40foo.de&n=&i=12345678901&s=23456789012";
|
|
let claire_id = join_securejoin(&bob, qr_stale).await?;
|
|
let chat = Chat::load_from_db(&bob, claire_id).await?;
|
|
assert!(!claire_id.is_special());
|
|
assert_eq!(chat.typ, Chattype::Single);
|
|
assert!(bob.pop_sent_msg().await.payload().contains("claire@foo.de"));
|
|
|
|
// subsequent scans shall abort existing ones or run concurrently -
|
|
// but they must not fail as otherwise the whole qr scanning becomes unusable until restart.
|
|
let qr = get_securejoin_qr(&alice, None).await?;
|
|
let alice_id = join_securejoin(&bob, &qr).await?;
|
|
let chat = Chat::load_from_db(&bob, alice_id).await?;
|
|
assert!(!alice_id.is_special());
|
|
assert_eq!(chat.typ, Chattype::Single);
|
|
assert_ne!(claire_id, alice_id);
|
|
assert!(bob
|
|
.pop_sent_msg()
|
|
.await
|
|
.payload()
|
|
.contains("alice@example.org"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_secure_join() -> Result<()> {
|
|
let mut tcm = TestContextManager::new();
|
|
let alice = tcm.alice().await;
|
|
let bob = tcm.bob().await;
|
|
|
|
// We start with empty chatlists.
|
|
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 0);
|
|
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0);
|
|
|
|
let alice_chatid =
|
|
chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?;
|
|
|
|
// Step 1: Generate QR-code, secure-join implied by chatid
|
|
let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Step 2: Bob scans QR-code, sends vg-request
|
|
let bob_chatid = join_securejoin(&bob.ctx, &qr).await?;
|
|
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
|
|
|
|
let sent = bob.pop_sent_msg().await;
|
|
assert_eq!(
|
|
sent.recipient(),
|
|
EmailAddress::new("alice@example.org").unwrap()
|
|
);
|
|
let msg = alice.parse_msg(&sent).await;
|
|
assert!(!msg.was_encrypted());
|
|
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vg-request");
|
|
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
|
|
|
|
// Step 3: Alice receives vg-request, sends vg-auth-required
|
|
alice.recv_msg(&sent).await;
|
|
|
|
let sent = alice.pop_sent_msg().await;
|
|
let msg = bob.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vg-auth-required"
|
|
);
|
|
|
|
// Step 4: Bob receives vg-auth-required, sends vg-request-with-auth
|
|
bob.recv_msg(&sent).await;
|
|
let sent = bob.pop_sent_msg().await;
|
|
|
|
// Check Bob emitted the JoinerProgress event.
|
|
let event = bob
|
|
.evtracker
|
|
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
|
|
.await;
|
|
match event {
|
|
EventType::SecurejoinJoinerProgress {
|
|
contact_id,
|
|
progress,
|
|
} => {
|
|
let alice_contact_id =
|
|
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
|
|
.await
|
|
.expect("Error looking up contact")
|
|
.expect("Contact not found");
|
|
assert_eq!(contact_id, alice_contact_id);
|
|
assert_eq!(progress, 400);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
|
|
// Check Bob sent the right handshake message.
|
|
let msg = alice.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vg-request-with-auth"
|
|
);
|
|
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
|
|
let bob_fp = load_self_public_key(&bob.ctx).await?.fingerprint();
|
|
assert_eq!(
|
|
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
|
|
bob_fp.hex()
|
|
);
|
|
|
|
// Alice should not yet have Bob verified
|
|
let contact_bob_id =
|
|
Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown)
|
|
.await?
|
|
.expect("Contact not found");
|
|
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?;
|
|
assert_eq!(
|
|
contact_bob.is_verified(&alice.ctx).await?,
|
|
VerifiedStatus::Unverified
|
|
);
|
|
|
|
// Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added
|
|
alice.recv_msg(&sent).await;
|
|
assert_eq!(
|
|
contact_bob.is_verified(&alice.ctx).await?,
|
|
VerifiedStatus::BidirectVerified
|
|
);
|
|
|
|
let sent = alice.pop_sent_msg().await;
|
|
let msg = bob.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vg-member-added"
|
|
);
|
|
|
|
{
|
|
// Now Alice's chat with Bob should still be hidden, the verified message should
|
|
// appear in the group chat.
|
|
|
|
let chat = alice.get_chat(&bob).await;
|
|
assert_eq!(
|
|
chat.blocked,
|
|
Blocked::Yes,
|
|
"Alice's 1:1 chat with Bob is not hidden"
|
|
);
|
|
// There should be 3 messages in the chat:
|
|
// - The ChatProtectionEnabled message
|
|
// - bob@example.net verified
|
|
// - You added member bob@example.net
|
|
let msg = get_chat_msg(&alice, alice_chatid, 1, 3).await;
|
|
assert!(msg.is_info());
|
|
assert!(msg.get_text().contains("bob@example.net verified"));
|
|
}
|
|
|
|
// Bob should not yet have Alice verified
|
|
let contact_alice_id =
|
|
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
|
|
.await
|
|
.expect("Error looking up contact")
|
|
.expect("Contact not found");
|
|
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?;
|
|
assert_eq!(
|
|
contact_bob.is_verified(&bob.ctx).await?,
|
|
VerifiedStatus::Unverified
|
|
);
|
|
|
|
// Step 7: Bob receives vg-member-added, sends vg-member-added-received
|
|
bob.recv_msg(&sent).await;
|
|
{
|
|
// Bob has Alice verified, message shows up in the group chat.
|
|
assert_eq!(
|
|
contact_alice.is_verified(&bob.ctx).await?,
|
|
VerifiedStatus::BidirectVerified
|
|
);
|
|
let chat = bob.get_chat(&alice).await;
|
|
assert_eq!(
|
|
chat.blocked,
|
|
Blocked::Yes,
|
|
"Bob's 1:1 chat with Alice is not hidden"
|
|
);
|
|
for item in chat::get_chat_msgs(&bob.ctx, bob_chatid).await.unwrap() {
|
|
if let chat::ChatItem::Message { msg_id } = item {
|
|
let msg = Message::load_from_db(&bob.ctx, msg_id).await.unwrap();
|
|
let text = msg.get_text();
|
|
println!("msg {msg_id} text: {text}");
|
|
}
|
|
}
|
|
let mut msg_iter = chat::get_chat_msgs(&bob.ctx, bob_chatid)
|
|
.await
|
|
.unwrap()
|
|
.into_iter();
|
|
loop {
|
|
match msg_iter.next() {
|
|
Some(chat::ChatItem::Message { msg_id }) => {
|
|
let msg = Message::load_from_db(&bob.ctx, msg_id).await.unwrap();
|
|
let text = msg.get_text();
|
|
match text.contains("alice@example.org verified") {
|
|
true => {
|
|
assert!(msg.is_info());
|
|
break;
|
|
}
|
|
false => continue,
|
|
}
|
|
}
|
|
Some(_) => continue,
|
|
None => panic!("Verified message not found in Bob's group chat"),
|
|
}
|
|
}
|
|
}
|
|
|
|
let sent = bob.pop_sent_msg().await;
|
|
let msg = alice.parse_msg(&sent).await;
|
|
assert!(msg.was_encrypted());
|
|
assert_eq!(
|
|
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
|
"vg-member-added-received"
|
|
);
|
|
|
|
let bob_chat = Chat::load_from_db(&bob.ctx, bob_chatid).await?;
|
|
assert!(bob_chat.is_protected());
|
|
assert!(bob_chat.typ == Chattype::Group);
|
|
|
|
// On this "happy path", Alice and Bob get only a group-chat where all information are added to.
|
|
// The one-to-one chats are used internally for the hidden handshake messages,
|
|
// however, should not be visible in the UIs.
|
|
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 1);
|
|
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
|
|
|
|
// If Bob then sends a direct message to alice, however, the one-to-one with Alice should appear.
|
|
let bobs_chat_with_alice = bob.create_chat(&alice).await;
|
|
let sent = bob.send_text(bobs_chat_with_alice.id, "Hello").await;
|
|
alice.recv_msg(&sent).await;
|
|
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 2);
|
|
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_adhoc_group_no_qr() -> Result<()> {
|
|
let alice = TestContext::new_alice().await;
|
|
|
|
let mime = br#"Subject: First thread
|
|
Message-ID: first@example.org
|
|
To: Alice <alice@example.org>, Bob <bob@example.net>
|
|
From: Claire <claire@example.org>
|
|
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
|
|
|
First thread."#;
|
|
|
|
receive_imf(&alice, mime, false).await?;
|
|
let msg = alice.get_last_msg().await;
|
|
let chat_id = msg.chat_id;
|
|
|
|
assert!(get_securejoin_qr(&alice, Some(chat_id)).await.is_err());
|
|
Ok(())
|
|
}
|
|
}
|