From 9cd000c4f2afc223ea92dbb244c92dffb3effbff Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sun, 9 Jul 2023 14:06:45 +0200 Subject: [PATCH] feat: Verified 1:1 chats (#4315) Implement #4188 BREAKING CHANGE: Remove unused DC_STR_PROTECTION_(EN)ABLED* strings BREAKING CHANGE: Remove unused dc_set_chat_protection() --- deltachat-ffi/deltachat.h | 87 ++- deltachat-ffi/src/lib.rs | 36 +- deltachat-repl/src/cmdline.rs | 29 +- node/lib/context.ts | 17 - node/src/module.c | 13 - src/chat.rs | 270 +++++----- src/config.rs | 10 + src/context.rs | 6 + src/lib.rs | 2 +- src/mimefactory.rs | 12 +- src/peerstate.rs | 25 + src/receive_imf.rs | 189 +++---- src/securejoin.rs | 63 ++- src/securejoin/bob.rs | 8 + src/stock_str.rs | 114 ++-- src/test_utils.rs | 68 ++- src/tests.rs | 1 + src/tests/aeap.rs | 15 +- src/tests/verified_chats.rs | 508 ++++++++++++++++++ test-data/golden/test_outgoing_mua_msg | 7 + ...test_verified_oneonone_chat_enable_disable | 12 + 21 files changed, 1037 insertions(+), 455 deletions(-) create mode 100644 src/tests/verified_chats.rs create mode 100644 test-data/golden/test_outgoing_mua_msg create mode 100644 test-data/golden/test_verified_oneonone_chat_enable_disable diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 5dcd263e2..22e6df1e2 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -485,6 +485,13 @@ char* dc_get_blobdir (const dc_context_t* context); * to not mess up with non-delivery-reports or read-receipts. * 0=no limit (default). * Changes affect future messages only. + * - `verified_one_on_one_chats` = Feature flag for verified 1:1 chats; the UI should set it + * to 1 if it supports verified 1:1 chats. + * Regardless of this setting, `dc_chat_is_protected()` returns true while the key is verified, + * and when the key changes, an info message is posted into the chat. + * 0=Nothing else happens when the key changes. + * 1=After the key changed, `dc_chat_can_send()` returns false and `dc_chat_is_protection_broken()` returns true + * until `dc_accept_chat()` is called. * - `ui.*` = All keys prefixed by `ui.` can be used by the user-interfaces for system-specific purposes. * The prefix should be followed by the system and maybe subsystem, * e.g. `ui.desktop.foo`, `ui.desktop.linux.bar`, `ui.android.foo`, `ui.dc40.bar`, `ui.bot.simplebot.baz`. @@ -1470,24 +1477,6 @@ dc_array_t* dc_get_chat_media (dc_context_t* context, uint32_t ch uint32_t dc_get_next_media (dc_context_t* context, uint32_t msg_id, int dir, int msg_type, int msg_type2, int msg_type3); -/** - * Enable or disable protection against active attacks. - * To enable protection, it is needed that all members are verified; - * if this condition is met, end-to-end-encryption is always enabled - * and only the verified keys are used. - * - * Sends out #DC_EVENT_CHAT_MODIFIED on changes - * and #DC_EVENT_MSGS_CHANGED if a status message was sent. - * - * @memberof dc_context_t - * @param context The context object as returned from dc_context_new(). - * @param chat_id The ID of the chat to change the protection for. - * @param protect 1=protect chat, 0=unprotect chat - * @return 1=success, 0=error, e.g. some members may be unverified - */ -int dc_set_chat_protection (dc_context_t* context, uint32_t chat_id, int protect); - - /** * Set chat visibility to pinned, archived or normal. * @@ -3712,7 +3701,6 @@ int dc_chat_can_send (const dc_chat_t* chat); * Check if a chat is protected. * Protected chats contain only verified members and encryption is always enabled. * Protected chats are created using dc_create_group_chat() by setting the 'protect' parameter to 1. - * The status can be changed using dc_set_chat_protection(). * * @memberof dc_chat_t * @param chat The chat object. @@ -3721,6 +3709,26 @@ int dc_chat_can_send (const dc_chat_t* chat); int dc_chat_is_protected (const dc_chat_t* chat); +/** + * Checks if the chat was protected, and then an incoming message broke this protection. + * + * This function is only useful if the UI enabled the `verified_one_on_one_chats` feature flag, + * otherwise it will return false for all chats. + * + * 1:1 chats are automatically set as protected when a contact is verified. + * When a message comes in that is not encrypted / signed correctly, + * the chat is automatically set as unprotected again. + * dc_chat_is_protection_broken() will return true until dc_accept_chat() is called. + * + * The UI should let the user confirm that this is OK with a message like + * `Bob sent a message from another device. Tap to learn more` and then call dc_accept_chat(). + * @memberof dc_chat_t + * @param chat The chat object. + * @return 1=chat protection broken, 0=otherwise. + */ +int dc_chat_is_protection_broken (const dc_chat_t* chat); + + /** * Check if locations are sent to the chat * at the time the object was created using dc_get_chat(). @@ -4315,7 +4323,7 @@ int dc_msg_is_forwarded (const dc_msg_t* msg); * Check if the message is an informational message, created by the * device or by another users. Such messages are not "typed" by the user but * created due to other actions, - * e.g. dc_set_chat_name(), dc_set_chat_profile_image(), dc_set_chat_protection() + * e.g. dc_set_chat_name(), dc_set_chat_profile_image(), * or dc_add_contact_to_chat(). * * These messages are typically shown in the center of the chat view, @@ -6749,15 +6757,6 @@ void dc_event_unref(dc_event_t* event); /// Used in error strings. #define DC_STR_ERROR_NO_NETWORK 87 -/// "Chat protection enabled." -/// - -/// @deprecated Deprecated, replaced by DC_STR_MSG_YOU_ENABLED_PROTECTION and DC_STR_MSG_PROTECTION_ENABLED_BY. -#define DC_STR_PROTECTION_ENABLED 88 - -/// @deprecated Deprecated, replaced by DC_STR_MSG_YOU_DISABLED_PROTECTION and DC_STR_MSG_PROTECTION_DISABLED_BY. -#define DC_STR_PROTECTION_DISABLED 89 - /// "Reply" /// /// Used in summaries. @@ -7202,26 +7201,6 @@ void dc_event_unref(dc_event_t* event); /// `%2$s` will be replaced by name and address of the contact. #define DC_STR_EPHEMERAL_TIMER_WEEKS_BY_OTHER 157 -/// "You enabled chat protection." -/// -/// Used in status messages. -#define DC_STR_PROTECTION_ENABLED_BY_YOU 158 - -/// "Chat protection enabled by %1$s." -/// -/// `%1$s` will be replaced by name and address of the contact. -/// -/// Used in status messages. -#define DC_STR_PROTECTION_ENABLED_BY_OTHER 159 - -/// "You disabled chat protection." -#define DC_STR_PROTECTION_DISABLED_BY_YOU 160 - -/// "Chat protection disabled by %1$s." -/// -/// `%1$s` will be replaced by name and address of the contact. -#define DC_STR_PROTECTION_DISABLED_BY_OTHER 161 - /// "Scan to set up second device for %1$s" /// /// `%1$s` will be replaced by name and address of the account. @@ -7232,6 +7211,16 @@ void dc_event_unref(dc_event_t* event); /// Used as a device message after a successful backup transfer. #define DC_STR_BACKUP_TRANSFER_MSG_BODY 163 +/// "Messages are guaranteed to be end-to-end encrypted from now on." +/// +/// Used in info messages. +#define DC_STR_CHAT_PROTECTION_ENABLED 170 + +/// "%1$s sent a message from another device." +/// +/// Used in info messages. +#define DC_STR_CHAT_PROTECTION_DISABLED 171 + /** * @} */ diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index f6c4c7e9f..b97a12d5a 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1429,32 +1429,6 @@ pub unsafe extern "C" fn dc_get_next_media( }) } -#[no_mangle] -pub unsafe extern "C" fn dc_set_chat_protection( - context: *mut dc_context_t, - chat_id: u32, - protect: libc::c_int, -) -> libc::c_int { - if context.is_null() { - eprintln!("ignoring careless call to dc_set_chat_protection()"); - return 0; - } - let ctx = &*context; - let protect = if let Some(s) = ProtectionStatus::from_i32(protect) { - s - } else { - warn!(ctx, "bad protect-value for dc_set_chat_protection()"); - return 0; - }; - - block_on(async move { - match ChatId::new(chat_id).set_protection(ctx, protect).await { - Ok(()) => 1, - Err(_) => 0, - } - }) -} - #[no_mangle] pub unsafe extern "C" fn dc_set_chat_visibility( context: *mut dc_context_t, @@ -3084,6 +3058,16 @@ pub unsafe extern "C" fn dc_chat_is_protected(chat: *mut dc_chat_t) -> libc::c_i ffi_chat.chat.is_protected() as libc::c_int } +#[no_mangle] +pub unsafe extern "C" fn dc_chat_is_protection_broken(chat: *mut dc_chat_t) -> libc::c_int { + if chat.is_null() { + eprintln!("ignoring careless call to dc_chat_is_protection_broken()"); + return 0; + } + let ffi_chat = &*chat; + ffi_chat.chat.is_protection_broken() as libc::c_int +} + #[no_mangle] pub unsafe extern "C" fn dc_chat_is_sending_locations(chat: *mut dc_chat_t) -> libc::c_int { if chat.is_null() { diff --git a/deltachat-repl/src/cmdline.rs b/deltachat-repl/src/cmdline.rs index c3e473dbe..7bb4c40bc 100644 --- a/deltachat-repl/src/cmdline.rs +++ b/deltachat-repl/src/cmdline.rs @@ -18,6 +18,7 @@ use deltachat::imex::*; use deltachat::location; use deltachat::log::LogExt; use deltachat::message::{self, Message, MessageState, MsgId, Viewtype}; +use deltachat::mimeparser::SystemMessage; use deltachat::peerstate::*; use deltachat::qr::*; use deltachat::reaction::send_reaction; @@ -210,7 +211,17 @@ async fn log_msg(context: &Context, prefix: impl AsRef, msg: &Message) { } else { "[FRESH]" }, - if msg.is_info() { "[INFO]" } else { "" }, + if msg.is_info() { + if msg.get_info_type() == SystemMessage::ChatProtectionEnabled { + "[INFO πŸ›‘οΈ]" + } else if msg.get_info_type() == SystemMessage::ChatProtectionDisabled { + "[INFO πŸ›‘οΈβŒ]" + } else { + "[INFO]" + } + } else { + "" + }, if msg.get_viewtype() == Viewtype::VideochatInvitation { format!( "[VIDEOCHAT-INVITATION: {}, type={}]", @@ -395,8 +406,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu unpin \n\ mute []\n\ unmute \n\ - protect \n\ - unprotect \n\ delchat \n\ accept \n\ decline \n\ @@ -1056,20 +1065,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu }; chat::set_muted(&context, chat_id, duration).await?; } - "protect" | "unprotect" => { - ensure!(!arg1.is_empty(), "Argument missing."); - let chat_id = ChatId::new(arg1.parse()?); - chat_id - .set_protection( - &context, - match arg0 { - "protect" => ProtectionStatus::Protected, - "unprotect" => ProtectionStatus::Unprotected, - _ => unreachable!("arg0={:?}", arg0), - }, - ) - .await?; - } "delchat" => { ensure!(!arg1.is_empty(), "Argument missing."); let chat_id = ChatId::new(arg1.parse()?); diff --git a/node/lib/context.ts b/node/lib/context.ts index 4c926ba78..a219881a1 100644 --- a/node/lib/context.ts +++ b/node/lib/context.ts @@ -699,23 +699,6 @@ export class Context extends EventEmitter { ) } - /** - * - * @param chatId - * @param protect - * @returns success boolean - */ - setChatProtection(chatId: number, protect: boolean) { - debug(`setChatProtection ${chatId} ${protect}`) - return Boolean( - binding.dcn_set_chat_protection( - this.dcn_context, - Number(chatId), - protect ? 1 : 0 - ) - ) - } - getChatEphemeralTimer(chatId: number): number { debug(`getChatEphemeralTimer ${chatId}`) return binding.dcn_get_chat_ephemeral_timer( diff --git a/node/src/module.c b/node/src/module.c index 3d3045017..de24e2c90 100644 --- a/node/src/module.c +++ b/node/src/module.c @@ -1399,18 +1399,6 @@ NAPI_METHOD(dcn_set_chat_name) { NAPI_RETURN_INT32(result); } -NAPI_METHOD(dcn_set_chat_protection) { - NAPI_ARGV(3); - NAPI_DCN_CONTEXT(); - NAPI_ARGV_UINT32(chat_id, 1); - NAPI_ARGV_INT32(protect, 1); - - int result = dc_set_chat_protection(dcn_context->dc_context, - chat_id, - protect); - NAPI_RETURN_INT32(result); -} - NAPI_METHOD(dcn_get_chat_ephemeral_timer) { NAPI_ARGV(2); NAPI_DCN_CONTEXT(); @@ -3491,7 +3479,6 @@ NAPI_INIT() { NAPI_EXPORT_FUNCTION(dcn_send_msg); NAPI_EXPORT_FUNCTION(dcn_send_videochat_invitation); NAPI_EXPORT_FUNCTION(dcn_set_chat_name); - NAPI_EXPORT_FUNCTION(dcn_set_chat_protection); NAPI_EXPORT_FUNCTION(dcn_get_chat_ephemeral_timer); NAPI_EXPORT_FUNCTION(dcn_set_chat_ephemeral_timer); NAPI_EXPORT_FUNCTION(dcn_set_chat_profile_image); diff --git a/src/chat.rs b/src/chat.rs index 950eb9e90..cc34d79ab 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -86,6 +86,14 @@ pub enum ProtectionStatus { /// /// All members of the chat must be verified. Protected = 1, + + /// The chat was protected, but now a new message came in + /// which was not encrypted / signed correctly. + /// The user has to confirm that this is OK. + /// + /// We only do this in 1:1 chats; in group chats, the chat just + /// stays protected. + ProtectionBroken = 3, // `2` was never used as a value. } /// The reason why messages cannot be sent to the chat. @@ -102,6 +110,10 @@ pub(crate) enum CantSendReason { /// The chat is a contact request, it needs to be accepted before sending a message. ContactRequest, + /// The chat was protected, but now a new message came in + /// which was not encrypted / signed correctly. + ProtectionBroken, + /// Mailing list without known List-Post header. ReadOnlyMailingList, @@ -118,6 +130,10 @@ impl fmt::Display for CantSendReason { f, "contact request chat should be accepted before sending messages" ), + Self::ProtectionBroken => write!( + f, + "accept that the encryption isn't verified anymore before sending messages" + ), Self::ReadOnlyMailingList => { write!(f, "mailing list does not have a know post address") } @@ -270,6 +286,7 @@ impl ChatId { param: Option, ) -> Result { let grpname = strip_rtlo_characters(grpname); + let smeared_time = create_smeared_timestamp(context); let row_id = context.sql.insert( "INSERT INTO chats (type, name, grpid, blocked, created_timestamp, protected, param) VALUES(?, ?, ?, ?, ?, ?, ?);", @@ -278,13 +295,20 @@ impl ChatId { &grpname, grpid, create_blocked, - create_smeared_timestamp(context), + smeared_time, create_protected, param.unwrap_or_default(), ), ).await?; let chat_id = ChatId::new(u32::try_from(row_id)?); + + if create_protected == ProtectionStatus::Protected { + chat_id + .add_protection_msg(context, ProtectionStatus::Protected, None, smeared_time) + .await?; + } + info!( context, "Created group/mailinglist '{}' grpid={} as {}, blocked={}.", @@ -374,6 +398,13 @@ impl ChatId { match chat.typ { Chattype::Undefined => bail!("Can't accept chat of undefined chattype"), + Chattype::Single if chat.protected == ProtectionStatus::ProtectionBroken => { + // The chat was in the 'Request' state because the protection was broken. + // The user clicked 'Accept', so, now we want to set the status to Unprotected again: + chat.id + .inner_set_protection(context, ProtectionStatus::Unprotected) + .await?; + } Chattype::Single | Chattype::Group | Chattype::Broadcast => { // User has "created a chat" with all these contacts. // @@ -400,20 +431,19 @@ impl ChatId { /// Sets protection without sending a message. /// - /// Used when a message arrives indicating that someone else has - /// changed the protection value for a chat. + /// Returns whether the protection status was actually modified. pub(crate) async fn inner_set_protection( self, context: &Context, protect: ProtectionStatus, - ) -> Result<()> { - ensure!(!self.is_special(), "Invalid chat-id."); + ) -> Result { + ensure!(!self.is_special(), "Invalid chat-id {self}."); let chat = Chat::load_from_db(context, self).await?; if protect == chat.protected { info!(context, "Protection status unchanged for {}.", self); - return Ok(()); + return Ok(false); } match protect { @@ -430,7 +460,7 @@ impl ChatId { Chattype::Mailinglist => bail!("Cannot protect mailing lists"), Chattype::Undefined => bail!("Undefined group type"), }, - ProtectionStatus::Unprotected => {} + ProtectionStatus::Unprotected | ProtectionStatus::ProtectionBroken => {} }; context @@ -443,68 +473,58 @@ impl ChatId { // make sure, the receivers will get all keys self.reset_gossiped_timestamp(context).await?; - Ok(()) + Ok(true) } - /// Send protected status message to the chat. + /// Adds an info message to the chat, telling the user that the protection status changed. /// - /// This sends the message with the protected status change to the chat, - /// notifying the user on this device as well as the other users in the chat. + /// Params: /// - /// If `promote` is false this means, the message must not be sent out - /// and only a local info message should be added to the chat. - /// This is used when protection is enabled implicitly or when a chat is not yet promoted. + /// * `contact_id`: In a 1:1 chat, pass the chat partner's contact id. + /// * `timestamp_sort` is used as the timestamp of the added message + /// and should be the timestamp of the change happening. pub(crate) async fn add_protection_msg( self, context: &Context, protect: ProtectionStatus, - promote: bool, - from_id: ContactId, + contact_id: Option, + timestamp_sort: i64, ) -> Result<()> { - let text = context.stock_protection_msg(protect, from_id).await; + let text = context.stock_protection_msg(protect, contact_id).await; let cmd = match protect { ProtectionStatus::Protected => SystemMessage::ChatProtectionEnabled, ProtectionStatus::Unprotected => SystemMessage::ChatProtectionDisabled, + ProtectionStatus::ProtectionBroken => SystemMessage::ChatProtectionDisabled, }; - - if promote { - let mut msg = Message { - viewtype: Viewtype::Text, - text, - ..Default::default() - }; - msg.param.set_cmd(cmd); - send_msg(context, self, &mut msg).await?; - } else { - add_info_msg_with_cmd( - context, - self, - &text, - cmd, - create_smeared_timestamp(context), - None, - None, - None, - ) - .await?; - } + add_info_msg_with_cmd(context, self, &text, cmd, timestamp_sort, None, None, None).await?; Ok(()) } /// Sets protection and sends or adds a message. - pub async fn set_protection(self, context: &Context, protect: ProtectionStatus) -> Result<()> { - ensure!(!self.is_special(), "set protection: invalid chat-id."); - - let chat = Chat::load_from_db(context, self).await?; - - if let Err(e) = self.inner_set_protection(context, protect).await { - error!(context, "Cannot set protection: {e:#}."); // make error user-visible - return Err(e); + /// + /// `timestamp_sort` is used as the timestamp of the added message + /// and should be the timestamp of the change happening. + pub(crate) async fn set_protection( + self, + context: &Context, + protect: ProtectionStatus, + timestamp_sort: i64, + contact_id: Option, + ) -> Result<()> { + match self.inner_set_protection(context, protect).await { + Ok(protection_status_modified) => { + if protection_status_modified { + self.add_protection_msg(context, protect, contact_id, timestamp_sort) + .await?; + } + Ok(()) + } + Err(e) => { + error!(context, "Cannot set protection: {e:#}."); // make error user-visible + Err(e) + } } - - self.add_protection_msg(context, protect, chat.is_promoted(), ContactId::SELF) - .await } /// Archives or unarchives a chat. @@ -1141,7 +1161,7 @@ pub struct Chat { pub grpid: String, /// Whether the chat is blocked, unblocked or a contact request. - pub(crate) blocked: Blocked, + pub blocked: Blocked, /// Additional chat parameters stored in the database. pub param: Params, @@ -1153,7 +1173,7 @@ pub struct Chat { pub mute_duration: MuteDuration, /// If the chat is protected (verified). - protected: ProtectionStatus, + pub(crate) protected: ProtectionStatus, } impl Chat { @@ -1247,6 +1267,8 @@ impl Chat { Some(DeviceChat) } else if self.is_contact_request() { Some(ContactRequest) + } else if self.is_protection_broken() { + Some(ProtectionBroken) } else if self.is_mailing_list() && self.param.get(Param::ListPost).is_none_or_empty() { Some(ReadOnlyMailingList) } else if !self.is_self_in_chat(context).await? { @@ -1410,6 +1432,27 @@ impl Chat { self.protected == ProtectionStatus::Protected } + /// Returns true if the chat was protected, and then an incoming message broke this protection. + /// + /// This function is only useful if the UI enabled the `verified_one_on_one_chats` feature flag, + /// otherwise it will return false for all chats. + /// + /// 1:1 chats are automatically set as protected when a contact is verified. + /// When a message comes in that is not encrypted / signed correctly, + /// the chat is automatically set as unprotected again. + /// `is_protection_broken()` will return true until `chat_id.accept()` is called. + /// + /// The UI should let the user confirm that this is OK with a message like + /// `Bob sent a message from another device. Tap to learn more` + /// and then call `chat_id.accept()`. + pub fn is_protection_broken(&self) -> bool { + match self.protected { + ProtectionStatus::Protected => false, + ProtectionStatus::Unprotected => false, + ProtectionStatus::ProtectionBroken => true, + } + } + /// Returns true if location streaming is enabled in the chat. pub fn is_sending_locations(&self) -> bool { self.is_sending_locations @@ -1440,15 +1483,6 @@ impl Chat { let mut to_id = 0; let mut location_id = 0; - if let Some(reason) = self.why_cant_send(context).await? { - if self.typ == Chattype::Group && reason == CantSendReason::NotAMember { - context.emit_event(EventType::ErrorSelfNotInGroup( - "Cannot send message; self not in group.".into(), - )); - } - bail!("Cannot send message to {}: {}", self.id, reason); - } - let from = context.get_primary_self_addr().await?; let new_rfc724_mid = { let grpid = match self.typ { @@ -1964,19 +1998,28 @@ impl ChatIdBlocked { _ => (), } + let peerstate = Peerstate::from_addr(context, contact.get_addr()).await?; + let protected = peerstate.map_or(false, |p| p.is_using_verified_key()); + let smeared_time = create_smeared_timestamp(context); + let chat_id = context .sql .transaction(move |transaction| { transaction.execute( "INSERT INTO chats - (type, name, param, blocked, created_timestamp) - VALUES(?, ?, ?, ?, ?)", + (type, name, param, blocked, created_timestamp, protected) + VALUES(?, ?, ?, ?, ?, ?)", ( Chattype::Single, chat_name, params.to_string(), create_blocked as u8, - create_smeared_timestamp(context), + smeared_time, + if protected { + ProtectionStatus::Protected + } else { + ProtectionStatus::Unprotected + }, ), )?; let chat_id = ChatId::new( @@ -1997,6 +2040,17 @@ impl ChatIdBlocked { }) .await?; + if protected { + chat_id + .add_protection_msg( + context, + ProtectionStatus::Protected, + Some(contact_id), + smeared_time, + ) + .await?; + } + match contact_id { ContactId::SELF => update_saved_messages_icon(context).await?, ContactId::DEVICE => update_device_icon(context).await?, @@ -2100,7 +2154,13 @@ async fn prepare_msg_common( // Check if the chat can be sent to. if let Some(reason) = chat.why_cant_send(context).await? { - bail!("cannot send to {}: {}", chat_id, reason); + if reason == CantSendReason::ProtectionBroken + && msg.param.get_cmd() == SystemMessage::SecurejoinMessage + { + // Send out the message, the securejoin message is supposed to repair the verification + } else { + bail!("cannot send to {chat_id}: {reason}"); + } } // check current MessageState for drafts (to keep msg_id) ... @@ -2850,18 +2910,14 @@ pub async fn create_group_chat( let grpid = create_id(); + let timestamp = create_smeared_timestamp(context); let row_id = context .sql .insert( "INSERT INTO chats (type, name, grpid, param, created_timestamp) VALUES(?, ?, ?, \'U=1\', ?);", - ( - Chattype::Group, - chat_name, - grpid, - create_smeared_timestamp(context), - ), + (Chattype::Group, chat_name, grpid, timestamp), ) .await?; @@ -2873,9 +2929,9 @@ pub async fn create_group_chat( context.emit_msgs_changed_without_ids(); if protect == ProtectionStatus::Protected { - // this part is to stay compatible to verified groups, - // in some future, we will drop the "protect"-flag from create_group_chat() - chat_id.inner_set_protection(context, protect).await?; + chat_id + .set_protection(context, protect, timestamp, None) + .await?; } Ok(chat_id) @@ -5131,72 +5187,6 @@ mod tests { Ok(()) } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_set_protection() -> Result<()> { - let t = TestContext::new_alice().await; - t.set_config_bool(Config::BccSelf, false).await?; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; - let chat = Chat::load_from_db(&t, chat_id).await?; - assert!(!chat.is_protected()); - assert!(chat.is_unpromoted()); - - // enable protection on unpromoted chat, the info-message is added via add_info_msg() - chat_id - .set_protection(&t, ProtectionStatus::Protected) - .await?; - - let chat = Chat::load_from_db(&t, chat_id).await?; - assert!(chat.is_protected()); - assert!(chat.is_unpromoted()); - - let msgs = get_chat_msgs(&t, chat_id).await?; - assert_eq!(msgs.len(), 1); - - let msg = t.get_last_msg_in(chat_id).await; - assert!(msg.is_info()); - assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionEnabled); - assert_eq!(msg.get_state(), MessageState::InNoticed); - - // disable protection again, still unpromoted - chat_id - .set_protection(&t, ProtectionStatus::Unprotected) - .await?; - - let chat = Chat::load_from_db(&t, chat_id).await?; - assert!(!chat.is_protected()); - assert!(chat.is_unpromoted()); - - let msg = t.get_last_msg_in(chat_id).await; - assert!(msg.is_info()); - assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionDisabled); - assert_eq!(msg.get_state(), MessageState::InNoticed); - - // send a message, this switches to promoted state - send_text_msg(&t, chat_id, "hi!".to_string()).await?; - - let chat = Chat::load_from_db(&t, chat_id).await?; - assert!(!chat.is_protected()); - assert!(!chat.is_unpromoted()); - - let msgs = get_chat_msgs(&t, chat_id).await?; - assert_eq!(msgs.len(), 3); - - // enable protection on promoted chat, the info-message is sent via send_msg() this time - chat_id - .set_protection(&t, ProtectionStatus::Protected) - .await?; - let chat = Chat::load_from_db(&t, chat_id).await?; - assert!(chat.is_protected()); - assert!(!chat.is_unpromoted()); - - let msg = t.get_last_msg_in(chat_id).await; - assert!(msg.is_info()); - assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionEnabled); - assert_eq!(msg.get_state(), MessageState::OutDelivered); // as bcc-self is disabled and there is nobody else in the chat - - Ok(()) - } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_lookup_by_contact_id() { let ctx = TestContext::new_alice().await; diff --git a/src/config.rs b/src/config.rs index b90b444c3..2e243ba53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -311,6 +311,16 @@ pub enum Config { /// Last message processed by the bot. LastMsgId, + + /// Feature flag for verified 1:1 chats; the UI should set it + /// to 1 if it supports verified 1:1 chats. + /// Regardless of this setting, `chat.is_protected()` returns true while the key is verified, + /// and when the key changes, an info message is posted into the chat. + /// 0=Nothing else happens when the key changes. + /// 1=After the key changed, `can_send()` returns false and `is_protection_broken()` returns true + /// until `chat_id.accept()` is called. + #[strum(props(default = "0"))] + VerifiedOneOnOneChats, } impl Context { diff --git a/src/context.rs b/src/context.rs index 270676031..8209f445f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -755,6 +755,12 @@ impl Context { "last_msg_id", self.get_config_int(Config::LastMsgId).await?.to_string(), ); + res.insert( + "verified_one_on_one_chats", + self.get_config_bool(Config::VerifiedOneOnOneChats) + .await? + .to_string(), + ); let elapsed = self.creation_time.elapsed(); res.insert("uptime", duration_to_str(elapsed.unwrap_or_default())); diff --git a/src/lib.rs b/src/lib.rs index 28778eb19..b1230e2d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,6 @@ missing_debug_implementations, missing_docs, clippy::all, - clippy::indexing_slicing, clippy::wildcard_imports, clippy::needless_borrow, clippy::cast_lossless, @@ -17,6 +16,7 @@ clippy::explicit_into_iter_loop, clippy::cloned_instead_of_copied )] +#![cfg_attr(not(test), warn(clippy::indexing_slicing))] #![allow( clippy::match_bool, clippy::mixed_read_write_in_expression, diff --git a/src/mimefactory.rs b/src/mimefactory.rs index ce0dac303..4581958d2 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -896,7 +896,17 @@ impl<'a> MimeFactory<'a> { let mut placeholdertext = None; let mut meta_part = None; - if chat.is_protected() { + let send_verified_headers = match chat.typ { + Chattype::Undefined => bail!("Undefined chat type"), + // In single chats, the protection status isn't necessarily the same for both sides, + // so we don't send the Chat-Verified header: + Chattype::Single => false, + Chattype::Group => true, + // Mailinglists and broadcast lists can actually never be verified: + Chattype::Mailinglist => false, + Chattype::Broadcast => false, + }; + if chat.is_protected() && send_verified_headers { headers .protected .push(Header::new("Chat-Verified".to_string(), "1".to_string())); diff --git a/src/peerstate.rs b/src/peerstate.rs index 8a08a175d..cbe4227f5 100644 --- a/src/peerstate.rs +++ b/src/peerstate.rs @@ -392,6 +392,31 @@ impl Peerstate { } } + /// Returns a reference to the contact's public key fingerprint. + /// + /// Similar to [`Self::peek_key`], but returns the fingerprint instead of the key. + fn peek_key_fingerprint(&self, min_verified: PeerstateVerifiedStatus) -> Option<&Fingerprint> { + match min_verified { + PeerstateVerifiedStatus::BidirectVerified => self.verified_key_fingerprint.as_ref(), + PeerstateVerifiedStatus::Unverified => self + .public_key_fingerprint + .as_ref() + .or(self.gossip_key_fingerprint.as_ref()), + } + } + + /// Returns true if the key used for opportunistic encryption in the 1:1 chat + /// is the same as the verified key. + /// + /// Note that verified groups always use the verified key no matter if the + /// opportunistic key matches or not. + pub(crate) fn is_using_verified_key(&self) -> bool { + let verified = self.peek_key_fingerprint(PeerstateVerifiedStatus::BidirectVerified); + + verified.is_some() + && verified == self.peek_key_fingerprint(PeerstateVerifiedStatus::Unverified) + } + /// Set this peerstate to verified /// Make sure to call `self.save_to_db` to save these changes /// Params: diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 6db785650..c1d58a6d8 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -4,7 +4,7 @@ use std::cmp::min; use std::collections::HashSet; use std::convert::TryFrom; -use anyhow::{bail, ensure, Context as _, Result}; +use anyhow::{Context as _, Result}; use mailparse::{parse_mail, SingleInfo}; use num_traits::FromPrimitive; use once_cell::sync::Lazy; @@ -14,7 +14,7 @@ use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus}; use crate::config::Config; use crate::constants::{Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH}; use crate::contact::{ - may_be_valid_addr, normalize_name, Contact, ContactAddress, ContactId, Origin, VerifiedStatus, + may_be_valid_addr, normalize_name, Contact, ContactAddress, ContactId, Origin, }; use crate::context::Context; use crate::debug_logging::maybe_set_logging_xdc_inner; @@ -593,12 +593,17 @@ async fn add_parts( // In lookup_chat_by_reply() and create_or_lookup_group(), it can happen that the message is put into a chat // but the From-address is not a member of this chat. - if let Some(chat_id) = chat_id { - if !chat::is_contact_in_chat(context, chat_id, from_id).await? { - let chat = Chat::load_from_db(context, chat_id).await?; + if let Some(group_chat_id) = chat_id { + if !chat::is_contact_in_chat(context, group_chat_id, from_id).await? { + let chat = Chat::load_from_db(context, group_chat_id).await?; if chat.is_protected() { - let s = stock_str::unknown_sender_for_chat(context).await; - mime_parser.repl_msg_by_error(&s); + if chat.typ == Chattype::Single { + // Just assign the message to the 1:1 chat with the actual sender instead. + chat_id = None; + } else { + let s = stock_str::unknown_sender_for_chat(context).await; + mime_parser.repl_msg_by_error(&s); + } } else { // In non-protected chats, just mark the sender as overridden. Therefore, the UI will prepend `~` // to the sender's name, indicating to the user that he/she is not part of the group. @@ -614,7 +619,7 @@ async fn add_parts( context, mime_parser, sent_timestamp, - chat_id, + group_chat_id, from_id, to_ids, ) @@ -717,6 +722,44 @@ async fn add_parts( ); } } + + // The next block checks if the message was sent with verified encryption + // and sets the protection of the 1:1 chat accordingly. + if is_partial_download.is_none() + && mime_parser.get_header(HeaderDef::SecureJoin).is_none() + && !is_mdn + { + let mut new_protection = match has_verified_encryption( + context, + mime_parser, + from_id, + to_ids, + Chattype::Single, + ) + .await? + { + VerifiedEncryption::Verified => ProtectionStatus::Protected, + VerifiedEncryption::NotVerified(_) => ProtectionStatus::Unprotected, + }; + + let chat = Chat::load_from_db(context, chat_id).await?; + + if chat.protected != new_protection { + if new_protection == ProtectionStatus::Unprotected + && context + .get_config_bool(Config::VerifiedOneOnOneChats) + .await? + { + new_protection = ProtectionStatus::ProtectionBroken; + } + + let sort_timestamp = + calc_sort_timestamp(context, sent_timestamp, chat_id, true).await?; + chat_id + .set_protection(context, new_protection, sort_timestamp, Some(from_id)) + .await?; + } + } } } @@ -983,42 +1026,14 @@ async fn add_parts( // if a chat is protected and the message is fully downloaded, check additional properties if !chat_id.is_special() && is_partial_download.is_none() { let chat = Chat::load_from_db(context, chat_id).await?; - let new_status = match mime_parser.is_system_message { - SystemMessage::ChatProtectionEnabled => Some(ProtectionStatus::Protected), - SystemMessage::ChatProtectionDisabled => Some(ProtectionStatus::Unprotected), - _ => None, - }; - if chat.is_protected() || new_status.is_some() { - if let Err(err) = check_verified_properties(context, mime_parser, from_id, to_ids).await + if chat.is_protected() { + if let VerifiedEncryption::NotVerified(err) = + has_verified_encryption(context, mime_parser, from_id, to_ids, chat.typ).await? { warn!(context, "Verification problem: {err:#}."); let s = format!("{err}. See 'Info' for more details"); mime_parser.repl_msg_by_error(&s); - } else { - // change chat protection only when verification check passes - if let Some(new_status) = new_status { - if chat_id - .update_timestamp( - context, - Param::ProtectionSettingsTimestamp, - sent_timestamp, - ) - .await? - { - if let Err(e) = chat_id.inner_set_protection(context, new_status).await { - chat::add_info_msg( - context, - chat_id, - &format!("Cannot set protection: {e}"), - sort_timestamp, - ) - .await?; - // do not return an error as this would result in retrying the message - } - } - better_msg = Some(context.stock_protection_msg(new_status, from_id).await); - } } } } @@ -1520,7 +1535,9 @@ async fn create_or_lookup_group( } let create_protected = if mime_parser.get_header(HeaderDef::ChatVerified).is_some() { - if let Err(err) = check_verified_properties(context, mime_parser, from_id, to_ids).await { + if let VerifiedEncryption::NotVerified(err) = + has_verified_encryption(context, mime_parser, from_id, to_ids, Chattype::Group).await? + { warn!(context, "Verification problem: {err:#}."); let s = format!("{err}. See 'Info' for more details"); mime_parser.repl_msg_by_error(&s); @@ -1768,20 +1785,6 @@ async fn apply_group_changes( } } - if mime_parser.get_header(HeaderDef::ChatVerified).is_some() { - if let Err(err) = check_verified_properties(context, mime_parser, from_id, to_ids).await { - warn!(context, "Verification problem: {err:#}."); - let s = format!("{err}. See 'Info' for more details"); - mime_parser.repl_msg_by_error(&s); - } - - if !chat.is_protected() { - chat_id - .inner_set_protection(context, ProtectionStatus::Protected) - .await?; - } - } - // Recreate the member list. if recreate_member_list { if !chat::is_contact_in_chat(context, chat_id, from_id).await? { @@ -2118,49 +2121,53 @@ async fn create_adhoc_group( Ok(Some(new_chat_id)) } -async fn check_verified_properties( +enum VerifiedEncryption { + Verified, + NotVerified(String), // The string contains the reason why it's not verified +} + +/// Checks whether the message is allowed to appear in a protected chat. +/// +/// This means that it is encrypted, signed with a verified key, +/// and if it's a group, all the recipients are verified. +async fn has_verified_encryption( context: &Context, mimeparser: &MimeMessage, from_id: ContactId, to_ids: &[ContactId], -) -> Result<()> { - let contact = Contact::get_by_id(context, from_id).await?; + chat_type: Chattype, +) -> Result { + use VerifiedEncryption::*; - ensure!(mimeparser.was_encrypted(), "This message is not encrypted"); - - if mimeparser.get_header(HeaderDef::ChatVerified).is_none() { - // we do not fail here currently, this would exclude (a) non-deltas - // and (b) deltas with different protection views across multiple devices. - // for group creation or protection enabled/disabled, however, Chat-Verified is respected. - warn!( - context, - "{} did not mark message as protected.", - contact.get_addr() - ); + if from_id == ContactId::SELF && chat_type == Chattype::Single { + // For outgoing emails in the 1:1 chat, we have an exception that + // they are allowed to be unencrypted: + // 1. They can't be an attack (they are outgoing, not incoming) + // 2. Probably the unencryptedness is just a temporary state, after all + // the user obviously still uses DC + // -> Showing info messages everytime would be a lot of noise + // 3. The info messages that are shown to the user ("Your chat partner + // likely reinstalled DC" or similar) would be wrong. + return Ok(Verified); } + if !mimeparser.was_encrypted() { + return Ok(NotVerified("This message is not encrypted".to_string())); + }; + // ensure, the contact is verified // and the message is signed with a verified key of the sender. // this check is skipped for SELF as there is no proper SELF-peerstate // and results in group-splits otherwise. if from_id != ContactId::SELF { - let peerstate = Peerstate::from_addr(context, contact.get_addr()).await?; + let Some(peerstate) = &mimeparser.decryption_info.peerstate else { + return Ok(NotVerified("No peerstate, the contact isn't verified".to_string())); + }; - if peerstate.is_none() - || contact.is_verified_ex(context, peerstate.as_ref()).await? - != VerifiedStatus::BidirectVerified - { - bail!( - "Sender of this message is not verified: {}", - contact.get_addr() - ); - } - - if let Some(peerstate) = peerstate { - ensure!( - peerstate.has_verified_key(&mimeparser.signatures), - "The message was sent with non-verified encryption" - ); + if !peerstate.has_verified_key(&mimeparser.signatures) { + return Ok(NotVerified( + "The message was sent with non-verified encryption".to_string(), + )); } } @@ -2172,7 +2179,7 @@ async fn check_verified_properties( .collect::>(); if to_ids.is_empty() { - return Ok(()); + return Ok(Verified); } let rows = context @@ -2196,10 +2203,12 @@ async fn check_verified_properties( ) .await?; + let contact = Contact::get_by_id(context, from_id).await?; + for (to_addr, mut is_verified) in rows { info!( context, - "check_verified_properties: {:?} self={:?}.", + "has_verified_encryption: {:?} self={:?}.", to_addr, context.is_self_addr(&to_addr).await ); @@ -2233,13 +2242,13 @@ async fn check_verified_properties( } } if !is_verified { - bail!( + return Ok(NotVerified(format!( "{} is not a member of this protected chat", - to_addr.to_string() - ); + to_addr + ))); } } - Ok(()) + Ok(Verified) } /// Returns the last message referenced from `References` header if it is in the database. diff --git a/src/securejoin.rs b/src/securejoin.rs index 5ffa387ed..ba4b9743c 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -6,7 +6,7 @@ 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}; +use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus}; use crate::config::Config; use crate::constants::Blocked; use crate::contact::{Contact, ContactId, Origin, VerifiedStatus}; @@ -701,6 +701,14 @@ async fn secure_connection_established( 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?; + chat_id + .set_protection( + context, + ProtectionStatus::Protected, + time(), + Some(contact_id), + ) + .await?; context.emit_event(EventType::ChatModified(chat_id)); Ok(()) } @@ -783,6 +791,8 @@ mod tests { 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; @@ -921,7 +931,7 @@ mod tests { // Check Alice got the verified message in her 1:1 chat. { let chat = alice.create_chat(&bob).await; - let msg_id = chat::get_chat_msgs(&alice.ctx, chat.get_id()) + let msg_ids: Vec<_> = chat::get_chat_msgs(&alice.ctx, chat.get_id()) .await .unwrap() .into_iter() @@ -929,11 +939,17 @@ mod tests { chat::ChatItem::Message { msg_id } => Some(msg_id), _ => None, }) - .max() - .expect("No messages in Alice's 1:1 chat"); - let msg = Message::load_from_db(&alice.ctx, msg_id).await.unwrap(); - assert!(msg.is_info()); - assert!(msg.get_text().contains("bob@example.net verified")); + .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. @@ -969,7 +985,7 @@ mod tests { // Check Bob got the verified message in his 1:1 chat. { let chat = bob.create_chat(&alice).await; - let msg_id = chat::get_chat_msgs(&bob.ctx, chat.get_id()) + let msg_ids: Vec<_> = chat::get_chat_msgs(&bob.ctx, chat.get_id()) .await .unwrap() .into_iter() @@ -977,11 +993,16 @@ mod tests { chat::ChatItem::Message { msg_id } => Some(msg_id), _ => None, }) - .max() - .expect("No messages in Bob's 1:1 chat"); - let msg = Message::load_from_db(&bob.ctx, msg_id).await.unwrap(); - assert!(msg.is_info()); - assert!(msg.get_text().contains("alice@example.org verified")); + .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 @@ -1278,17 +1299,11 @@ mod tests { Blocked::Yes, "Alice's 1:1 chat with Bob is not hidden" ); - let msg_id = chat::get_chat_msgs(&alice.ctx, alice_chatid) - .await - .unwrap() - .into_iter() - .filter_map(|item| match item { - chat::ChatItem::Message { msg_id } => Some(msg_id), - _ => None, - }) - .min() - .expect("No messages in Alice's group chat"); - let msg = Message::load_from_db(&alice.ctx, msg_id).await.unwrap(); + // 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")); } diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 674fa94f7..c824a5367 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -222,6 +222,14 @@ impl BobState { let msg = stock_str::contact_verified(context, &contact).await; let chat_id = self.joining_chat_id(context).await?; chat::add_info_msg(context, chat_id, &msg, time()).await?; + chat_id + .set_protection( + context, + ProtectionStatus::Protected, + time(), + Some(contact.id), + ) + .await?; context.emit_event(EventType::ChatModified(chat_id)); Ok(()) } diff --git a/src/stock_str.rs b/src/stock_str.rs index ea19a2a72..e4d43701b 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -393,18 +393,6 @@ pub enum StockMessage { #[strum(props(fallback = "Message deletion timer is set to %1$s weeks by %2$s."))] MsgEphemeralTimerWeeksBy = 157, - #[strum(props(fallback = "You enabled chat protection."))] - YouEnabledProtection = 158, - - #[strum(props(fallback = "Chat protection enabled by %1$s."))] - ProtectionEnabledBy = 159, - - #[strum(props(fallback = "You disabled chat protection."))] - YouDisabledProtection = 160, - - #[strum(props(fallback = "Chat protection disabled by %1$s."))] - ProtectionDisabledBy = 161, - #[strum(props(fallback = "Scan to set up second device for %1$s"))] BackupTransferQr = 162, @@ -419,6 +407,12 @@ pub enum StockMessage { #[strum(props(fallback = "I left the group."))] MsgILeftGroup = 166, + + #[strum(props(fallback = "Messages are guaranteed to be end-to-end encrypted from now on."))] + ChatProtectionEnabled = 170, + + #[strum(props(fallback = "%1$s sent a message from another device."))] + ChatProtectionDisabled = 171, } impl StockMessage { @@ -515,13 +509,21 @@ trait StockStringMods: AsRef + Sized { } impl ContactId { - /// Get contact name for stock string. - async fn get_stock_name(self, context: &Context) -> String { + /// Get contact name and address for stock string, e.g. `Bob (bob@example.net)` + async fn get_stock_name_n_addr(self, context: &Context) -> String { Contact::get_by_id(context, self) .await .map(|contact| contact.get_name_n_addr()) .unwrap_or_else(|_| self.to_string()) } + + /// Get contact name, e.g. `Bob`, or `bob@exmple.net` if no name is set. + async fn get_stock_name(self, context: &Context) -> String { + Contact::get_by_id(context, self) + .await + .map(|contact| contact.get_display_name().to_string()) + .unwrap_or_else(|_| self.to_string()) + } } impl StockStringMods for String {} @@ -583,7 +585,7 @@ pub(crate) async fn msg_grp_name( .await .replace1(from_group) .replace2(to_group) - .replace3(&by_contact.get_stock_name(context).await) + .replace3(&by_contact.get_stock_name_n_addr(context).await) } } @@ -593,7 +595,7 @@ pub(crate) async fn msg_grp_img_changed(context: &Context, by_contact: ContactId } else { translated(context, StockMessage::MsgGrpImgChangedBy) .await - .replace1(&by_contact.get_stock_name(context).await) + .replace1(&by_contact.get_stock_name_n_addr(context).await) } } @@ -640,7 +642,7 @@ pub(crate) async fn msg_add_member_local( translated(context, StockMessage::MsgAddMemberBy) .await .replace1(whom) - .replace2(&by_contact.get_stock_name(context).await) + .replace2(&by_contact.get_stock_name_n_addr(context).await) } } @@ -687,7 +689,7 @@ pub(crate) async fn msg_del_member_local( translated(context, StockMessage::MsgDelMemberBy) .await .replace1(whom) - .replace2(&by_contact.get_stock_name(context).await) + .replace2(&by_contact.get_stock_name_n_addr(context).await) } } @@ -703,7 +705,7 @@ pub(crate) async fn msg_group_left_local(context: &Context, by_contact: ContactI } else { translated(context, StockMessage::MsgGroupLeftBy) .await - .replace1(&by_contact.get_stock_name(context).await) + .replace1(&by_contact.get_stock_name_n_addr(context).await) } } @@ -756,7 +758,7 @@ pub(crate) async fn msg_grp_img_deleted(context: &Context, by_contact: ContactId } else { translated(context, StockMessage::MsgGrpImgDeletedBy) .await - .replace1(&by_contact.get_stock_name(context).await) + .replace1(&by_contact.get_stock_name_n_addr(context).await) } } @@ -782,13 +784,9 @@ pub(crate) async fn secure_join_started( /// Stock string: `%1$s replied, waiting for being added to the group…`. pub(crate) async fn secure_join_replies(context: &Context, contact_id: ContactId) -> String { - if let Ok(contact) = Contact::get_by_id(context, contact_id).await { - translated(context, StockMessage::SecureJoinReplies) - .await - .replace1(contact.get_display_name()) - } else { - format!("secure_join_replies: unknown contact {contact_id}") - } + translated(context, StockMessage::SecureJoinReplies) + .await + .replace1(&contact_id.get_stock_name(context).await) } /// Stock string: `Scan to chat with %1$s`. @@ -881,7 +879,7 @@ pub(crate) async fn msg_location_enabled_by(context: &Context, contact: ContactI } else { translated(context, StockMessage::MsgLocationEnabledBy) .await - .replace1(&contact.get_stock_name(context).await) + .replace1(&contact.get_stock_name_n_addr(context).await) } } @@ -950,7 +948,7 @@ pub(crate) async fn msg_ephemeral_timer_disabled( } else { translated(context, StockMessage::MsgEphemeralTimerDisabledBy) .await - .replace1(&by_contact.get_stock_name(context).await) + .replace1(&by_contact.get_stock_name_n_addr(context).await) } } @@ -968,7 +966,7 @@ pub(crate) async fn msg_ephemeral_timer_enabled( translated(context, StockMessage::MsgEphemeralTimerEnabledBy) .await .replace1(timer) - .replace2(&by_contact.get_stock_name(context).await) + .replace2(&by_contact.get_stock_name_n_addr(context).await) } } @@ -979,7 +977,7 @@ pub(crate) async fn msg_ephemeral_timer_minute(context: &Context, by_contact: Co } else { translated(context, StockMessage::MsgEphemeralTimerMinuteBy) .await - .replace1(&by_contact.get_stock_name(context).await) + .replace1(&by_contact.get_stock_name_n_addr(context).await) } } @@ -990,7 +988,7 @@ pub(crate) async fn msg_ephemeral_timer_hour(context: &Context, by_contact: Cont } else { translated(context, StockMessage::MsgEphemeralTimerHourBy) .await - .replace1(&by_contact.get_stock_name(context).await) + .replace1(&by_contact.get_stock_name_n_addr(context).await) } } @@ -1001,7 +999,7 @@ pub(crate) async fn msg_ephemeral_timer_day(context: &Context, by_contact: Conta } else { translated(context, StockMessage::MsgEphemeralTimerDayBy) .await - .replace1(&by_contact.get_stock_name(context).await) + .replace1(&by_contact.get_stock_name_n_addr(context).await) } } @@ -1012,7 +1010,7 @@ pub(crate) async fn msg_ephemeral_timer_week(context: &Context, by_contact: Cont } else { translated(context, StockMessage::MsgEphemeralTimerWeekBy) .await - .replace1(&by_contact.get_stock_name(context).await) + .replace1(&by_contact.get_stock_name_n_addr(context).await) } } @@ -1053,26 +1051,16 @@ pub(crate) async fn error_no_network(context: &Context) -> String { translated(context, StockMessage::ErrorNoNetwork).await } -/// Stock string: `Chat protection enabled.`. -pub(crate) async fn protection_enabled(context: &Context, by_contact: ContactId) -> String { - if by_contact == ContactId::SELF { - translated(context, StockMessage::YouEnabledProtection).await - } else { - translated(context, StockMessage::ProtectionEnabledBy) - .await - .replace1(&by_contact.get_stock_name(context).await) - } +/// Stock string: `Messages are guaranteed to be end-to-end encrypted from now on.` +pub(crate) async fn chat_protection_enabled(context: &Context) -> String { + translated(context, StockMessage::ChatProtectionEnabled).await } -/// Stock string: `Chat protection disabled.`. -pub(crate) async fn protection_disabled(context: &Context, by_contact: ContactId) -> String { - if by_contact == ContactId::SELF { - translated(context, StockMessage::YouDisabledProtection).await - } else { - translated(context, StockMessage::ProtectionDisabledBy) - .await - .replace1(&by_contact.get_stock_name(context).await) - } +/// Stock string: `%1$s sent a message from another device.` +pub(crate) async fn chat_protection_disabled(context: &Context, contact_id: ContactId) -> String { + translated(context, StockMessage::ChatProtectionDisabled) + .await + .replace1(&contact_id.get_stock_name(context).await) } /// Stock string: `Reply`. @@ -1104,7 +1092,7 @@ pub(crate) async fn msg_ephemeral_timer_minutes( translated(context, StockMessage::MsgEphemeralTimerMinutesBy) .await .replace1(minutes) - .replace2(&by_contact.get_stock_name(context).await) + .replace2(&by_contact.get_stock_name_n_addr(context).await) } } @@ -1122,7 +1110,7 @@ pub(crate) async fn msg_ephemeral_timer_hours( translated(context, StockMessage::MsgEphemeralTimerHoursBy) .await .replace1(hours) - .replace2(&by_contact.get_stock_name(context).await) + .replace2(&by_contact.get_stock_name_n_addr(context).await) } } @@ -1140,7 +1128,7 @@ pub(crate) async fn msg_ephemeral_timer_days( translated(context, StockMessage::MsgEphemeralTimerDaysBy) .await .replace1(days) - .replace2(&by_contact.get_stock_name(context).await) + .replace2(&by_contact.get_stock_name_n_addr(context).await) } } @@ -1158,7 +1146,7 @@ pub(crate) async fn msg_ephemeral_timer_weeks( translated(context, StockMessage::MsgEphemeralTimerWeeksBy) .await .replace1(weeks) - .replace2(&by_contact.get_stock_name(context).await) + .replace2(&by_contact.get_stock_name_n_addr(context).await) } } @@ -1332,11 +1320,19 @@ impl Context { pub(crate) async fn stock_protection_msg( &self, protect: ProtectionStatus, - from_id: ContactId, + contact_id: Option, ) -> String { match protect { - ProtectionStatus::Unprotected => protection_enabled(self, from_id).await, - ProtectionStatus::Protected => protection_disabled(self, from_id).await, + ProtectionStatus::Unprotected | ProtectionStatus::ProtectionBroken => { + if let Some(contact_id) = contact_id { + chat_protection_disabled(self, contact_id).await + } else { + // In a group chat, it's not possible to downgrade verification. + // In a 1:1 chat, the `contact_id` always has to be provided. + "[Error] No contact_id given".to_string() + } + } + ProtectionStatus::Protected => chat_protection_enabled(self).await, } } diff --git a/src/test_utils.rs b/src/test_utils.rs index 52c4dd111..060256b0c 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -31,11 +31,14 @@ use crate::constants::Chattype; use crate::constants::{DC_GCL_NO_SPECIALS, DC_MSG_ID_DAYMARKER}; use crate::contact::{Contact, ContactAddress, ContactId, Modifier, Origin}; use crate::context::Context; +use crate::e2ee::EncryptHelper; use crate::events::{Event, EventType, Events}; use crate::key::{self, DcKey, KeyPair, KeyPairUse}; use crate::message::{update_msg_state, Message, MessageState, MsgId, Viewtype}; -use crate::mimeparser::MimeMessage; +use crate::mimeparser::{MimeMessage, SystemMessage}; +use crate::peerstate::Peerstate; use crate::receive_imf::receive_imf; +use crate::securejoin::{get_securejoin_qr, join_securejoin}; use crate::stock_str::StockStrings; use crate::tools::EmailAddress; @@ -108,9 +111,15 @@ impl TestContextManager { /// - Let one TestContext send a message /// - Let the other TestContext receive it and accept the chat /// - Assert that the message arrived - pub async fn send_recv_accept(&self, from: &TestContext, to: &TestContext, msg: &str) { + pub async fn send_recv_accept( + &self, + from: &TestContext, + to: &TestContext, + msg: &str, + ) -> Message { let received_msg = self.send_recv(from, to, msg).await; received_msg.chat_id.accept(to).await.unwrap(); + received_msg } /// - Let one TestContext send a message @@ -152,6 +161,27 @@ impl TestContextManager { new_addr ); } + + pub async fn execute_securejoin(&self, scanner: &TestContext, scanned: &TestContext) { + self.section(&format!( + "{} scans {}'s QR code", + scanner.name(), + scanned.name() + )); + + let qr = get_securejoin_qr(&scanned.ctx, None).await.unwrap(); + join_securejoin(&scanner.ctx, &qr).await.unwrap(); + + loop { + if let Some(sent) = scanner.pop_sent_msg_opt(Duration::ZERO).await { + scanned.recv_msg(&sent).await; + } else if let Some(sent) = scanned.pop_sent_msg_opt(Duration::ZERO).await { + scanner.recv_msg(&sent).await; + } else { + break; + } + } + } } #[derive(Debug, Clone, Default)] @@ -636,7 +666,7 @@ impl TestContext { // We're using `unwrap_or_default()` here so that if the file doesn't exist, // it can be created using `write` below. let expected = fs::read(&filename).await.unwrap_or_default(); - let expected = String::from_utf8(expected).unwrap(); + let expected = String::from_utf8(expected).unwrap().replace("\r\n", "\n"); if (std::env::var("UPDATE_GOLDEN_TESTS") == Ok("1".to_string())) && actual != expected { fs::write(&filename, &actual) .await @@ -1008,6 +1038,26 @@ fn print_logevent(logevent: &LogEvent) { } } +/// Saves the other account's public key as verified. +pub(crate) async fn mark_as_verified(this: &TestContext, other: &TestContext) { + let mut peerstate = Peerstate::from_header( + &EncryptHelper::new(other).await.unwrap().get_aheader(), + // We have to give 0 as the time, not the current time: + // The time is going to be saved in peerstate.last_seen. + // The code in `peerstate.rs` then compares `if message_time > self.last_seen`, + // and many similar checks in peerstate.rs, and doesn't allow changes otherwise. + // Giving the current time would mean that message_time == peerstate.last_seen, + // so changes would not be allowed. + // This might lead to flaky tests. + 0, + ); + + peerstate.verified_key = peerstate.public_key.clone(); + peerstate.verified_key_fingerprint = peerstate.public_key_fingerprint.clone(); + + peerstate.save_to_db(&this.sql).await.unwrap(); +} + /// Pretty-print an event to stdout /// /// Done during tests this is captured by `cargo test` and associated with the test itself. @@ -1114,7 +1164,17 @@ async fn write_msg(context: &Context, prefix: &str, msg: &Message, buf: &mut Str } else { "[FRESH]" }, - if msg.is_info() { "[INFO]" } else { "" }, + if msg.is_info() { + if msg.get_info_type() == SystemMessage::ChatProtectionEnabled { + "[INFO πŸ›‘οΈ]" + } else if msg.get_info_type() == SystemMessage::ChatProtectionDisabled { + "[INFO πŸ›‘οΈβŒ]" + } else { + "[INFO]" + } + } else { + "" + }, if msg.get_viewtype() == Viewtype::VideochatInvitation { format!( "[VIDEOCHAT-INVITATION: {}, type={}]", diff --git a/src/tests.rs b/src/tests.rs index 9067f1962..3b417c0d4 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1 +1,2 @@ mod aeap; +mod verified_chats; diff --git a/src/tests/aeap.rs b/src/tests/aeap.rs index 0c43e8c17..f5f03ba83 100644 --- a/src/tests/aeap.rs +++ b/src/tests/aeap.rs @@ -8,10 +8,10 @@ use crate::contact; use crate::contact::Contact; use crate::contact::ContactId; use crate::message::Message; -use crate::peerstate; use crate::peerstate::Peerstate; use crate::receive_imf::receive_imf; use crate::stock_str; +use crate::test_utils::mark_as_verified; use crate::test_utils::TestContext; use crate::test_utils::TestContextManager; @@ -327,19 +327,6 @@ async fn check_no_transition_done(groups: &[ChatId], old_alice_addr: &str, bob: } } -async fn mark_as_verified(this: &TestContext, other: &TestContext) { - let other_addr = other.get_primary_self_addr().await.unwrap(); - let mut peerstate = peerstate::Peerstate::from_addr(this, &other_addr) - .await - .unwrap() - .unwrap(); - - peerstate.verified_key = peerstate.public_key.clone(); - peerstate.verified_key_fingerprint = peerstate.public_key_fingerprint.clone(); - - peerstate.save_to_db(&this.sql).await.unwrap(); -} - async fn get_last_info_msg(t: &TestContext, chat_id: ChatId) -> Option { let msgs = chat::get_chat_msgs_ex( &t.ctx, diff --git a/src/tests/verified_chats.rs b/src/tests/verified_chats.rs new file mode 100644 index 000000000..1967a320d --- /dev/null +++ b/src/tests/verified_chats.rs @@ -0,0 +1,508 @@ +use anyhow::Result; +use pretty_assertions::assert_eq; + +use crate::chat::{Chat, ProtectionStatus}; +use crate::config::Config; +use crate::contact::VerifiedStatus; +use crate::contact::{Contact, Origin}; +use crate::message::Message; +use crate::mimefactory::MimeFactory; +use crate::mimeparser::SystemMessage; +use crate::receive_imf::receive_imf; +use crate::stock_str; +use crate::test_utils::{get_chat_msg, mark_as_verified, TestContext, TestContextManager}; +use crate::{e2ee, message}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_verified_oneonone_chat_broken_by_classical() { + check_verified_oneonone_chat(true).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_verified_oneonone_chat_broken_by_device_change() { + check_verified_oneonone_chat(false).await; +} + +async fn check_verified_oneonone_chat(broken_by_classical_email: bool) { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + enable_verified_oneonone_chats(&[&alice, &bob]).await; + + tcm.execute_securejoin(&alice, &bob).await; + + assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + assert_verified(&bob, &alice, ProtectionStatus::Protected).await; + + if broken_by_classical_email { + tcm.section("Bob uses a classical MUA to send a message to Alice"); + receive_imf( + &alice, + b"Subject: Re: Message from alice\r\n\ + From: \r\n\ + To: \r\n\ + Date: Mon, 12 Dec 2022 14:33:39 +0000\r\n\ + Message-ID: \r\n\ + \r\n\ + Heyho!\r\n", + false, + ) + .await + .unwrap() + .unwrap(); + } else { + tcm.section("Bob sets up another Delta Chat device"); + let bob2 = TestContext::new().await; + enable_verified_oneonone_chats(&[&bob2]).await; + bob2.set_name("bob2"); + bob2.configure_addr("bob@example.net").await; + + tcm.send_recv(&bob2, &alice, "Using another device now") + .await; + } + + // Bob's contact is still verified, but the chat isn't marked as protected anymore + assert_verified(&alice, &bob, ProtectionStatus::ProtectionBroken).await; + + tcm.section("Bob sends another message from DC"); + tcm.send_recv(&bob, &alice, "Using DC again").await; + + let contact = alice.add_or_lookup_contact(&bob).await; + assert_eq!( + contact.is_verified(&alice.ctx).await.unwrap(), + VerifiedStatus::BidirectVerified + ); + + // Bob's chat is marked as verified again + assert_verified(&alice, &bob, ProtectionStatus::Protected).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_verified_oneonone_chat() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let fiona = tcm.fiona().await; + enable_verified_oneonone_chats(&[&alice, &bob, &fiona]).await; + + tcm.execute_securejoin(&alice, &bob).await; + tcm.execute_securejoin(&bob, &fiona).await; + assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + assert_verified(&bob, &alice, ProtectionStatus::Protected).await; + assert_verified(&bob, &fiona, ProtectionStatus::Protected).await; + assert_verified(&fiona, &bob, ProtectionStatus::Protected).await; + + let group_id = bob + .create_group_with_members( + ProtectionStatus::Protected, + "Group with everyone", + &[&alice, &fiona], + ) + .await; + assert_eq!( + get_chat_msg(&bob, group_id, 0, 1).await.get_info_type(), + SystemMessage::ChatProtectionEnabled + ); + + { + let sent = bob.send_text(group_id, "Heyho").await; + alice.recv_msg(&sent).await; + + let msg = fiona.recv_msg(&sent).await; + assert_eq!( + get_chat_msg(&fiona, msg.chat_id, 0, 2) + .await + .get_info_type(), + SystemMessage::ChatProtectionEnabled + ); + } + + // Alice and Fiona should now be verified because of gossip + let alice_fiona_contact = alice.add_or_lookup_contact(&fiona).await; + assert_eq!( + alice_fiona_contact.is_verified(&alice).await.unwrap(), + VerifiedStatus::BidirectVerified + ); + + // As soon as Alice creates a chat with Fiona, it should directly be protected + { + let chat = alice.create_chat(&fiona).await; + assert!(chat.is_protected()); + + let msg = alice.get_last_msg().await; + let expected_text = stock_str::chat_protection_enabled(&alice).await; + assert_eq!(msg.text, expected_text); + } + + // Fiona should also see the chat as protected + { + let rcvd = tcm.send_recv(&alice, &fiona, "Hi Fiona").await; + let alice_fiona_id = rcvd.chat_id; + let chat = Chat::load_from_db(&fiona, alice_fiona_id).await?; + assert!(chat.is_protected()); + + let msg0 = get_chat_msg(&fiona, chat.id, 0, 2).await; + let expected_text = stock_str::chat_protection_enabled(&fiona).await; + assert_eq!(msg0.text, expected_text); + } + + tcm.section("Fiona reinstalls DC"); + drop(fiona); + + let fiona_new = tcm.unconfigured().await; + enable_verified_oneonone_chats(&[&fiona_new]).await; + fiona_new.configure_addr("fiona@example.net").await; + e2ee::ensure_secret_key_exists(&fiona_new).await?; + + tcm.send_recv(&fiona_new, &alice, "I have a new device") + .await; + + // The chat should be and stay unprotected + { + let chat = alice.get_chat(&fiona_new).await.unwrap(); + assert!(!chat.is_protected()); + assert!(chat.is_protection_broken()); + + // After recreating the chat, it should still be unprotected + chat.id.delete(&alice).await?; + + let chat = alice.create_chat(&fiona_new).await; + assert!(!chat.is_protected()); + assert!(!chat.is_protection_broken()); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_unverified_oneonone_chat() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + enable_verified_oneonone_chats(&[&alice, &bob]).await; + + // A chat with an unknown contact should be created unprotected + let chat = alice.create_chat(&bob).await; + assert!(!chat.is_protected()); + assert!(!chat.is_protection_broken()); + + receive_imf( + &alice, + b"From: Bob \n\ + To: alice@example.org\n\ + Message-ID: <1234-2@example.org>\n\ + \n\ + hello\n", + false, + ) + .await?; + + chat.id.delete(&alice).await.unwrap(); + // Now Bob is a known contact, new chats should still be created unprotected + let chat = alice.create_chat(&bob).await; + assert!(!chat.is_protected()); + assert!(!chat.is_protection_broken()); + + tcm.send_recv(&bob, &alice, "hi").await; + chat.id.delete(&alice).await.unwrap(); + // Now we have a public key, new chats should still be created unprotected + let chat = alice.create_chat(&bob).await; + assert!(!chat.is_protected()); + assert!(!chat.is_protection_broken()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_degrade_verified_oneonone_chat() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + enable_verified_oneonone_chats(&[&alice, &bob]).await; + + mark_as_verified(&alice, &bob).await; + + let alice_chat = alice.create_chat(&bob).await; + assert!(alice_chat.is_protected()); + + receive_imf( + &alice, + b"From: Bob \n\ + To: alice@example.org\n\ + Message-ID: <1234-2@example.org>\n\ + \n\ + hello\n", + false, + ) + .await?; + + let contact_id = Contact::lookup_id_by_addr(&alice, "bob@example.net", Origin::Hidden) + .await? + .unwrap(); + + let msg0 = get_chat_msg(&alice, alice_chat.id, 0, 3).await; + let enabled = stock_str::chat_protection_enabled(&alice).await; + assert_eq!(msg0.text, enabled); + assert_eq!(msg0.param.get_cmd(), SystemMessage::ChatProtectionEnabled); + + let msg1 = get_chat_msg(&alice, alice_chat.id, 1, 3).await; + let disabled = stock_str::chat_protection_disabled(&alice, contact_id).await; + assert_eq!(msg1.text, disabled); + assert_eq!(msg1.param.get_cmd(), SystemMessage::ChatProtectionDisabled); + + let msg2 = get_chat_msg(&alice, alice_chat.id, 2, 3).await; + assert_eq!(msg2.text, "hello".to_string()); + assert!(!msg2.is_system_message()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_verified_oneonone_chat_enable_disable() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + enable_verified_oneonone_chats(&[&alice, &bob]).await; + + // Alice & Bob verify each other + mark_as_verified(&alice, &bob).await; + mark_as_verified(&bob, &alice).await; + + let chat = alice.create_chat(&bob).await; + assert!(chat.is_protected()); + + for alice_accepts_breakage in [true, false] { + // Bob uses Thunderbird to send a message + receive_imf( + &alice, + format!( + "From: Bob \n\ + To: alice@example.org\n\ + Message-ID: <1234-2{alice_accepts_breakage}@example.org>\n\ + \n\ + Message from Thunderbird\n" + ) + .as_bytes(), + false, + ) + .await?; + + let chat = alice.get_chat(&bob).await.unwrap(); + assert!(!chat.is_protected()); + assert!(chat.is_protection_broken()); + + if alice_accepts_breakage { + tcm.section("Alice clicks 'Accept' on the input-bar-dialog"); + chat.id.accept(&alice).await?; + let chat = alice.get_chat(&bob).await.unwrap(); + assert!(!chat.is_protected()); + assert!(!chat.is_protection_broken()); + } + + // Bob sends a message from DC again + tcm.send_recv(&bob, &alice, "Hello from DC").await; + let chat = alice.get_chat(&bob).await.unwrap(); + assert!(chat.is_protected()); + assert!(!chat.is_protection_broken()); + } + + alice + .golden_test_chat(chat.id, "test_verified_oneonone_chat_enable_disable") + .await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mdn_doesnt_disable_verification() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + enable_verified_oneonone_chats(&[&alice, &bob]).await; + bob.set_config_bool(Config::MdnsEnabled, true).await?; + + // Alice & Bob verify each other + mark_as_verified(&alice, &bob).await; + mark_as_verified(&bob, &alice).await; + + let rcvd = tcm.send_recv_accept(&alice, &bob, "Heyho").await; + message::markseen_msgs(&bob, vec![rcvd.id]).await?; + + let mimefactory = MimeFactory::from_mdn(&bob, &rcvd, vec![]).await?; + let rendered_msg = mimefactory.render(&bob).await?; + let body = rendered_msg.message; + receive_imf(&alice, body.as_bytes(), false).await.unwrap(); + + assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_outgoing_mua_msg() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + enable_verified_oneonone_chats(&[&alice, &bob]).await; + + mark_as_verified(&alice, &bob).await; + mark_as_verified(&bob, &alice).await; + + tcm.send_recv_accept(&bob, &alice, "Heyho from DC").await; + assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + + let sent = receive_imf( + &alice, + b"From: alice@example.org\n\ + To: bob@example.net\n\ + \n\ + One classical MUA message", + false, + ) + .await? + .unwrap(); + tcm.send_recv_accept(&alice, &bob, "Sending with DC again") + .await; + + alice + .golden_test_chat(sent.chat_id, "test_outgoing_mua_msg") + .await; + + Ok(()) +} + +/// If Bob answers unencrypted from another address with a classical MUA, +/// the message is under some circumstances still assigned to the original +/// chat (see lookup_chat_by_reply()); this is meant to make aliases +/// work nicely. +/// However, if the original chat is verified, the unencrypted message +/// must NOT be assigned to it (it would be replaced by an error +/// message in the verified chat, so, this would just be a usability issue, +/// not a security issue). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_reply() -> Result<()> { + for verified in [false, true] { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + enable_verified_oneonone_chats(&[&alice, &bob]).await; + + if verified { + mark_as_verified(&alice, &bob).await; + mark_as_verified(&bob, &alice).await; + } + + tcm.send_recv_accept(&bob, &alice, "Heyho from DC").await; + let encrypted_msg = tcm.send_recv_accept(&alice, &bob, "Heyho back").await; + + let unencrypted_msg = receive_imf( + &alice, + format!( + "From: bob@someotherdomain.org\n\ + To: some-alias-forwarding-to-alice@example.org\n\ + In-Reply-To: {}\n\ + \n\ + Weird reply", + encrypted_msg.rfc724_mid + ) + .as_bytes(), + false, + ) + .await? + .unwrap(); + + let unencrypted_msg = Message::load_from_db(&alice, unencrypted_msg.msg_ids[0]).await?; + assert_eq!(unencrypted_msg.text, "Weird reply"); + + if verified { + assert_ne!(unencrypted_msg.chat_id, encrypted_msg.chat_id); + } else { + assert_eq!(unencrypted_msg.chat_id, encrypted_msg.chat_id); + } + } + + Ok(()) +} + +/// Regression test for the following bug: +/// +/// - Scan your chat partner's QR Code +/// - They change devices +/// - They send you a message +/// - Without accepting the encryption downgrade, scan your chat partner's QR Code again +/// +/// -> The re-verification fails. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_break_protection_then_verify_again() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + enable_verified_oneonone_chats(&[&alice, &bob]).await; + + // Cave: Bob can't write a message to Alice here. + // If he did, alice would increase his peerstate's last_seen timestamp. + // Then, after Bob reinstalls DC, alice's `if message_time > last_seen*` + // checks would return false (there are many checks of this form in peerstate.rs). + // Therefore, during the securejoin, Alice wouldn't accept the new key + // and reject the securejoin. + + mark_as_verified(&alice, &bob).await; + mark_as_verified(&bob, &alice).await; + + alice + .create_chat(&bob) + .await + .id + .inner_set_protection(&alice, ProtectionStatus::Protected) + .await?; + assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + + tcm.section("Bob reinstalls DC"); + drop(bob); + let bob_new = tcm.unconfigured().await; + enable_verified_oneonone_chats(&[&bob_new]).await; + bob_new.configure_addr("bob@example.net").await; + e2ee::ensure_secret_key_exists(&bob_new).await?; + + tcm.send_recv(&bob_new, &alice, "I have a new device").await; + assert_verified(&alice, &bob_new, ProtectionStatus::ProtectionBroken).await; + assert!( + !alice + .get_chat(&bob_new) + .await + .unwrap() + .can_send(&alice) + .await? + ); + tcm.execute_securejoin(&alice, &bob_new).await; + assert_verified(&alice, &bob_new, ProtectionStatus::Protected).await; + + Ok(()) +} + +// ============== Helper Functions ============== + +async fn assert_verified(this: &TestContext, other: &TestContext, protected: ProtectionStatus) { + let contact = this.add_or_lookup_contact(other).await; + assert_eq!( + contact.is_verified(this).await.unwrap(), + VerifiedStatus::BidirectVerified + ); + + let chat = this.get_chat(other).await.unwrap(); + let (expect_protected, expect_broken) = match protected { + ProtectionStatus::Unprotected => (false, false), + ProtectionStatus::Protected => (true, false), + ProtectionStatus::ProtectionBroken => (false, true), + }; + assert_eq!(chat.is_protected(), expect_protected); + assert_eq!(chat.is_protection_broken(), expect_broken); +} + +async fn enable_verified_oneonone_chats(test_contexts: &[&TestContext]) { + for t in test_contexts { + t.set_config_bool(Config::VerifiedOneOnOneChats, true) + .await + .unwrap() + } +} diff --git a/test-data/golden/test_outgoing_mua_msg b/test-data/golden/test_outgoing_mua_msg new file mode 100644 index 000000000..549f84175 --- /dev/null +++ b/test-data/golden/test_outgoing_mua_msg @@ -0,0 +1,7 @@ +Single#Chat#10: bob@example.net [bob@example.net] πŸ›‘οΈ +-------------------------------------------------------------------------------- +Msg#10: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO πŸ›‘οΈ] +Msg#11πŸ”’: (Contact#Contact#10): Heyho from DC [FRESH] +Msg#12: Me (Contact#Contact#Self): One classical MUA message √ +Msg#13πŸ”’: Me (Contact#Contact#Self): Sending with DC again √ +-------------------------------------------------------------------------------- diff --git a/test-data/golden/test_verified_oneonone_chat_enable_disable b/test-data/golden/test_verified_oneonone_chat_enable_disable new file mode 100644 index 000000000..4da4a67d0 --- /dev/null +++ b/test-data/golden/test_verified_oneonone_chat_enable_disable @@ -0,0 +1,12 @@ +Single#Chat#10: Bob [bob@example.net] πŸ›‘οΈ +-------------------------------------------------------------------------------- +Msg#10: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO πŸ›‘οΈ] +Msg#11: info (Contact#Contact#Info): Bob sent a message from another device. [NOTICED][INFO πŸ›‘οΈβŒ] +Msg#12: (Contact#Contact#10): Message from Thunderbird [FRESH] +Msg#13: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO πŸ›‘οΈ] +Msg#14πŸ”’: (Contact#Contact#10): Hello from DC [FRESH] +Msg#15: info (Contact#Contact#Info): Bob sent a message from another device. [NOTICED][INFO πŸ›‘οΈβŒ] +Msg#16: (Contact#Contact#10): Message from Thunderbird [FRESH] +Msg#17: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO πŸ›‘οΈ] +Msg#18πŸ”’: (Contact#Contact#10): Hello from DC [FRESH] +--------------------------------------------------------------------------------