//! Verified contact protocol implementation as [specified by countermitm project](https://securejoin.readthedocs.io/en/latest/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; use crate::qr::check_qr; use crate::securejoin::bob::JoinerProgress; use crate::stock_str; use crate::sync::Sync::*; 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) -> Result { /*======================================================= ==== 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 { 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 { 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 { /*======================================================== ==== 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, ) -> 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); 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 { let chat_id_blocked = ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Not).await?; Ok(chat_id_blocked.id) } /// Checks fingerprint and marks the contact as forward verified /// if fingerprint matches. async fn verify_sender_by_fingerprint( context: &Context, fingerprint: &Fingerprint, contact_id: ContactId, ) -> Result { 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(mut peerstate) = peerstate { if peerstate .public_key_fingerprint .as_ref() .filter(|&fp| fp == fingerprint) .is_some() { if let Some(public_key) = &peerstate.public_key { let verifier = contact.get_addr().to_owned(); peerstate.set_verified(public_key.clone(), fingerprint.clone(), verifier)?; peerstate.prefer_encrypt = EncryptPreference::Mutual; peerstate.save_to_db(&context.sql).await?; 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 #[derive(Debug, PartialEq, Eq)] 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 { 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, "Received secure-join message {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); } 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]), ) .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 !verify_sender_by_fingerprint(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 Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else { 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).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 backward_verified = true; let fingerprint_found = mark_peer_as_verified( context, fingerprint.clone(), contact_addr, backward_verified, ) .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_id.regossip_keys(context).await?; 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, mime_message.timestamp_sent, ) .await?; chat::add_contact_to_chat_ex( context, Nosync, 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?, mime_message.timestamp_sent, ) .await?; send_alice_handshake_msg(context, contact_id, "vc-contact-confirm") .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" => { if let Some(mut bobstate) = BobState::from_db(&context.sql).await? { if !bobstate.is_msg_expected(context, step.as_str()) { warn!(context, "Unexpected vc-contact-confirm."); return Ok(HandshakeMessage::Ignore); } bobstate.step_contact_confirm(context).await?; bobstate.emit_progress(context, JoinerProgress::Succeeded); } 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); } if let Some(mut bobstate) = BobState::from_db(&context.sql).await? { if !bobstate.is_msg_expected(context, step.as_str()) { warn!(context, "Unexpected vg-member-added."); return Ok(HandshakeMessage::Propagate); } bobstate.step_contact_confirm(context).await?; bobstate.emit_progress(context, JoinerProgress::Succeeded); } Ok(HandshakeMessage::Propagate) } "vg-member-added-received" | "vc-contact-confirm-received" => { // Deprecated steps, delete them immediately. Ok(HandshakeMessage::Done) } _ => { warn!(context, "invalid step: {}", step); Ok(HandshakeMessage::Ignore) } } } /// Observe self-sent Securejoin message. /// /// In a multi-device-setup, there may be other devices that "see" the handshake messages. /// If we see self-sent messages encrypted+signed correctly with our key, /// we can make some conclusions of it. /// /// If we see self-sent {vc,vg}-request-with-auth, /// we know that we are Bob (joiner-observer) /// that just marked peer (Alice) as forward-verified /// either after receiving {vc,vg}-auth-required /// or immediately after scanning the QR-code /// if the key was already known. /// /// If we see self-sent vc-contact-confirm or vg-member-added message, /// we know that we are Alice (inviter-observer) /// that just marked peer (Bob) as forward (and backward)-verified /// in response to correct vc-request-with-auth message. /// /// In both cases we can mark the peer as forward-verified. pub(crate) async fn observe_securejoin_on_other_device( context: &Context, mime_message: &MimeMessage, contact_id: ContactId, ) -> Result { 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:?}."); if !matches!( step.as_str(), "vg-request-with-auth" | "vc-request-with-auth" | "vg-member-added" | "vc-contact-confirm" ) { return Ok(HandshakeMessage::Ignore); }; 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(); let Some(key) = mime_message.gossiped_keys.get(&addr) 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); }; let Some(mut peerstate) = Peerstate::from_addr(context, &addr).await? else { 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 Some(fingerprint) = peerstate.gossip_key_fingerprint.clone() else { 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); }; peerstate.set_verified(key.clone(), fingerprint, addr)?; peerstate.prefer_encrypt = EncryptPreference::Mutual; peerstate.save_to_db(&context.sql).await?; ChatId::set_protection_for_contact(context, contact_id, mime_message.timestamp_sent).await?; 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?; } if step.as_str() == "vg-member-added" { Ok(HandshakeMessage::Propagate) } else { Ok(HandshakeMessage::Ignore) } } async fn secure_connection_established( context: &Context, contact_id: ContactId, chat_id: ChatId, timestamp: i64, ) -> Result<()> { let private_chat_id = ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Yes) .await? .id; private_chat_id .set_protection( context, ProtectionStatus::Protected, timestamp, 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, backward_verified: bool, ) -> Result { let Some(ref mut peerstate) = Peerstate::from_fingerprint(context, &fingerprint).await? else { return Ok(false); }; let Some(ref public_key) = peerstate.public_key else { return Ok(false); }; peerstate.set_verified(public_key.clone(), fingerprint, verifier)?; peerstate.prefer_encrypt = EncryptPreference::Mutual; if 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?; 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::{remove_contact_from_chat, ProtectionStatus}; use crate::chatlist::Chatlist; use crate::constants::Chattype; use crate::contact::ContactAddress; 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, SystemTime}; use std::time::Duration; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_setup_contact() { test_setup_contact_ex(false).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_setup_contact_protection_timestamp() { test_setup_contact_ex(true).await } async fn test_setup_contact_ex(check_protection_timestamp: bool) { 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; let vc_request_with_auth_ts_sent = msg .get_header(HeaderDef::Date) .and_then(|value| mailparse::dateparse(value).ok()) .unwrap(); 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(), false); if check_protection_timestamp { SystemTime::shift(Duration::from_secs(3600)); } // 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(), true); // 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 = get_chat_msg(&alice, chat.get_id(), 0, 1).await; assert!(msg.is_info()); let expected_text = chat_protection_enabled(&alice).await; assert_eq!(msg.get_text(), expected_text); if check_protection_timestamp { assert_eq!(msg.timestamp_sort, vc_request_with_auth_ts_sent); } } // 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(), false); // Step 7: Bob receives vc-contact-confirm bob.recv_msg(&sent).await; assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), true); // Check Bob got the verified message in his 1:1 chat. let chat = bob.create_chat(&alice).await; let msg = get_chat_msg(&bob, chat.get_id(), 0, 1).await; assert!(msg.is_info()); let expected_text = chat_protection_enabled(&bob).await; assert_eq!(msg.get_text(), expected_text); } #[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, backward_verified_key_id: 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?, false); // 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?, true); 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?, false); // Step 7: Bob receives vc-contact-confirm bob.recv_msg(&sent).await; assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true); 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?, false); // 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?, true); 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 // - You added member bob@example.net let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await; assert!(msg.is_info()); let expected_text = chat_protection_enabled(&alice).await; assert_eq!(msg.get_text(), expected_text); } // 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?, false); // Step 7: Bob receives vg-member-added 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?, true); 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 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 , Bob From: Claire 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(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_unknown_sender() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; tcm.execute_securejoin(&alice, &bob).await; let alice_chat_id = alice .create_group_with_members(ProtectionStatus::Protected, "Group with Bob", &[&bob]) .await; let sent = alice.send_text(alice_chat_id, "Hi!").await; let bob_chat_id = bob.recv_msg(&sent).await.chat_id; let sent = bob.send_text(bob_chat_id, "Hi hi!").await; let alice_bob_contact_id = Contact::create(&alice, "Bob", "bob@example.net").await?; remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; // The message from Bob is delivered late, Bob is already removed. let msg = alice.recv_msg(&sent).await; assert_eq!(msg.text, "Hi hi!"); assert_eq!(msg.error.unwrap(), "Unknown sender for this chat."); Ok(()) } /// Tests that Bob gets Alice as verified /// if `vc-contact-confirm` is lost but Alice then sends /// a message to Bob in a verified 1:1 chat with a `Chat-Verified` header. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_lost_contact_confirm() { 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(); let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap(); join_securejoin(&bob.ctx, &qr).await.unwrap(); // vc-request let sent = bob.pop_sent_msg().await; alice.recv_msg(&sent).await; // vc-auth-required let sent = alice.pop_sent_msg().await; bob.recv_msg(&sent).await; // vc-request-with-auth let sent = bob.pop_sent_msg().await; alice.recv_msg(&sent).await; // Alice has Bob verified now. 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(), true); // Alice sends vc-contact-confirm, but it gets lost. let _sent_vc_contact_confirm = alice.pop_sent_msg().await; // Bob should not yet have Alice verified let contact_alice_id = Contact::lookup_id_by_addr(&bob, "alice@example.org", Origin::Unknown) .await .expect("Error looking up contact") .expect("Contact not found"); let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap(); assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), false); // Alice sends a text message to Bob. let received_hello = tcm.send_recv(&alice, &bob, "Hello!").await; let chat_id = received_hello.chat_id; let chat = Chat::load_from_db(&bob, chat_id).await.unwrap(); assert_eq!(chat.is_protected(), true); // Received text message in a verified 1:1 chat results in backward verification // and Bob now marks alice as verified. let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap(); assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), true); } }