diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index f15e75c92..fb5a2e12a 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -487,6 +487,7 @@ char* dc_get_blobdir (const dc_context_t* context); * 0 = Everybody (except explicitly blocked contacts), * 1 = Contacts (default, does not include contact requests), * 2 = Nobody (calls never result in a notification). + * - `force_encryption` = 1 (default) to force encryption, 0 to allow unencrypted messages. * * Also, there are configs that are only needed * if you want to use the deprecated dc_configure() API, such as: diff --git a/deltachat-rpc-client/tests/test_chatlist_events.py b/deltachat-rpc-client/tests/test_chatlist_events.py index f13769e07..a6152049d 100644 --- a/deltachat-rpc-client/tests/test_chatlist_events.py +++ b/deltachat-rpc-client/tests/test_chatlist_events.py @@ -87,6 +87,7 @@ def test_delivery_status_failed(acfactory: ACFactory) -> None: Test change status on chatlistitem when status changes failed """ (alice,) = acfactory.get_online_accounts(1) + alice.set_config("force_encryption", "0") invalid_contact = alice.create_contact("example@example.com", "invalid address") invalid_chat = alice.get_chat_by_id(alice._rpc.create_chat_by_contact_id(alice.id, invalid_contact.id)) diff --git a/python/tests/test_3_offline.py b/python/tests/test_3_offline.py index faacbb82e..056079fda 100644 --- a/python/tests/test_3_offline.py +++ b/python/tests/test_3_offline.py @@ -153,7 +153,8 @@ class TestOfflineContact: def test_delete_referenced_contact_hides_contact(self, acfactory): ac1 = acfactory.get_pseudo_configured_account() - contact1 = ac1.create_contact("some1@example.com", name="some1") + ac2 = acfactory.get_pseudo_configured_account() + contact1 = ac1.create_contact(ac2) msg = contact1.create_chat().send_text("one message") assert ac1.delete_contact(contact1) assert not msg.filemime @@ -185,8 +186,9 @@ class TestOfflineChat: return acfactory.get_pseudo_configured_account() @pytest.fixture() - def chat1(self, ac1): - return ac1.create_contact("some1@example.org", name="some1").create_chat() + def chat1(self, ac1, acfactory): + ac2 = acfactory.get_pseudo_configured_account() + return ac1.create_contact(ac2).create_chat() def test_display(self, chat1): str(chat1) @@ -404,7 +406,7 @@ class TestOfflineChat: contact2 = ac1.create_contact("display1 ", "real") assert contact2.name == "real" - def test_send_lots_of_offline_msgs(self, acfactory): + def test_send_lots_of_offline_msgs(self, acfactory, chat1): ac1 = acfactory.get_pseudo_configured_account() ac1.set_config("configured_mail_server", "example.org") ac1.set_config("configured_mail_user", "example.org") @@ -413,13 +415,13 @@ class TestOfflineChat: ac1.set_config("configured_send_user", "example.org") ac1.set_config("configured_send_pw", "example.org") ac1.start_io() - chat = ac1.create_contact("some1@example.org", name="some1").create_chat() for i in range(50): - chat.send_text("hello") + chat1.send_text("hello") def test_create_chat_simple(self, acfactory): ac1 = acfactory.get_pseudo_configured_account() - contact1 = ac1.create_contact("some1@example.org", name="some1") + ac2 = acfactory.get_pseudo_configured_account() + contact1 = ac1.create_contact(ac2) contact1.create_chat().send_text("hello") def test_chat_message_distinctions(self, ac1, chat1): diff --git a/python/tests/test_4_lowlevel.py b/python/tests/test_4_lowlevel.py index fa238154e..b70248fc2 100644 --- a/python/tests/test_4_lowlevel.py +++ b/python/tests/test_4_lowlevel.py @@ -152,7 +152,8 @@ def test_sig(): def test_markseen_invalid_message_ids(acfactory): ac1 = acfactory.get_pseudo_configured_account() - contact1 = ac1.create_contact("some1@example.com", name="some1") + ac2 = acfactory.get_pseudo_configured_account() + contact1 = ac1.create_contact(ac2) chat = contact1.create_chat() chat.send_text("one message") ac1._evtracker.get_matching("DC_EVENT_MSGS_CHANGED") diff --git a/src/chat.rs b/src/chat.rs index 6bf4239df..11197d261 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2823,7 +2823,12 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - .await?; } - let needs_encryption = msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default(); + let needs_encryption = msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default() + || (!msg + .param + .get_bool(Param::ForcePlaintext) + .unwrap_or_default() + && context.get_config_bool(Config::ForceEncryption).await?); let mimefactory = match MimeFactory::from_msg(context, msg.clone()).await { Ok(mf) => mf, Err(err) => { @@ -2890,11 +2895,26 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) - } if needs_encryption && !rendered_msg.is_encrypted { - /* unrecoverable */ - message::set_msg_failed( + let addr = context.get_config(Config::ConfiguredAddr).await?; + let text = stock_str::unencrypted_email( context, - msg, - "End-to-end-encryption unavailable unexpectedly.", + addr.unwrap_or_default() + .split('@') + .nth(1) + .unwrap_or_default(), + ) + .await; + message::set_msg_failed(context, msg, &text).await?; + add_info_msg_with_cmd( + context, + msg.chat_id, + &text, + SystemMessage::InvalidUnencryptedMail, + Some(msg.timestamp_sort), + msg.timestamp_sort, + None, + None, + None, ) .await?; bail!( diff --git a/src/config.rs b/src/config.rs index f0ca2fafb..777d704bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -486,6 +486,13 @@ pub enum Config { /// Experimental option denoting that the current profile is shared between multiple team members. /// For now, the only effect of this option is that seen flags are not synchronized. TeamProfile, + + /// Force encryption. + /// + /// When enabled, unencrypted messages cannot be sent + /// and incoming unencrypted messages are not fetched and not processed. + #[strum(props(default = "1"))] + ForceEncryption, } impl Config { @@ -501,7 +508,11 @@ impl Config { pub(crate) fn is_synced(&self) -> bool { matches!( self, - Self::Displayname | Self::MdnsEnabled | Self::Selfavatar | Self::Selfstatus, + Self::Displayname + | Self::MdnsEnabled + | Self::Selfavatar + | Self::Selfstatus + | Self::ForceEncryption, ) } diff --git a/src/context.rs b/src/context.rs index 95ce3c777..2eb37cb7d 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1056,6 +1056,12 @@ impl Context { "team_profile", self.get_config_bool(Config::TeamProfile).await?.to_string(), ); + res.insert( + "force_encryption", + self.get_config_bool(Config::ForceEncryption) + .await? + .to_string(), + ); let elapsed = time_elapsed(&self.creation_time); res.insert("uptime", duration_to_str(elapsed)); diff --git a/src/e2ee.rs b/src/e2ee.rs index 45edc4d6b..0779da3b8 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -109,9 +109,11 @@ pub async fn ensure_secret_key_exists(context: &Context) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::chat; use crate::chat::send_text_msg; use crate::config::Config; use crate::message::Message; + use crate::mimeparser::SystemMessage; use crate::receive_imf::receive_imf; use crate::test_utils::{TestContext, TestContextManager}; @@ -155,6 +157,28 @@ Sent with my Delta Chat Messenger: https://delta.chat"; ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_cannot_send_unencrypted_by_default() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let chat = alice.create_email_chat(bob).await; + + let mut msg = Message::new_text("Hello!".to_string()); + assert!(chat::send_msg(alice, chat.id, &mut msg).await.is_err()); + assert_eq!( + msg.error().unwrap(), + "\u{26a0}\u{fe0f} Your email provider example.org requires end-to-end encryption which is not setup yet." + ); + let info_msg = alice.get_last_msg().await; + assert_eq!( + info_msg.get_info_type(), + SystemMessage::InvalidUnencryptedMail + ); + + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_chatmail_can_send_unencrypted() -> Result<()> { let mut tcm = TestContextManager::new(); diff --git a/src/imap.rs b/src/imap.rs index bedc0376c..fbd0eaa98 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -1994,12 +1994,26 @@ pub(crate) async fn prefetch_should_download( // prevent_rename=true as this might be a mailing list message and in this case it would be bad if we rename the contact. // (prevent_rename is the last argument of from_field_to_contact_id()) + // New SecureJoin is fully encrypted, + // but for compatibility we still download legacy `Secure-Join: vc-request` messages. + let is_legacy_securejoin = headers.get_header_value(HeaderDef::SecureJoin).is_some(); + + let is_encrypted = headers + .get_header_value(HeaderDef::ContentType) + .is_some_and(|content_type| { + mailparse::parse_content_type(&content_type).mimetype == "multipart/encrypted" + }); + if flags.any(|f| f == Flag::Draft) { info!(context, "Ignoring draft message"); return Ok(false); } - let should_download = !blocked_contact || maybe_ndn; + let should_download = maybe_ndn + || (!blocked_contact + && (is_legacy_securejoin + || is_encrypted + || !context.get_config_bool(Config::ForceEncryption).await?)); Ok(should_download) } diff --git a/src/imap/session.rs b/src/imap/session.rs index 8d4e7e087..c9178d8f6 100644 --- a/src/imap/session.rs +++ b/src/imap/session.rs @@ -21,6 +21,8 @@ const PREFETCH_FLAGS: &str = "(UID RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\ DATE \ X-MICROSOFT-ORIGINAL-MESSAGE-ID \ FROM \ + CONTENT-TYPE \ + SECURE-JOIN \ CHAT-VERSION \ CHAT-IS-POST-MESSAGE \ AUTOCRYPT-SETUP-MESSAGE\ diff --git a/src/receive_imf.rs b/src/receive_imf.rs index a0d4c068b..ff586dde3 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -505,6 +505,14 @@ pub(crate) async fn receive_imf_inner( Ok(mime_parser) => mime_parser, }; + if !mime_parser.was_encrypted() + && mime_parser.get_header(HeaderDef::SecureJoin).is_none() + && context.get_config_bool(Config::ForceEncryption).await? + { + warn!(context, "Fetched unencrypted message, ignoring"); + return trash().await; + } + let rfc724_mid_orig = &mime_parser .get_rfc724_mid() .unwrap_or(rfc724_mid.to_string()); diff --git a/src/receive_imf/receive_imf_tests.rs b/src/receive_imf/receive_imf_tests.rs index bf0d09c5b..ad0957217 100644 --- a/src/receive_imf/receive_imf_tests.rs +++ b/src/receive_imf/receive_imf_tests.rs @@ -4494,12 +4494,12 @@ async fn test_outgoing_msg_forgery() -> Result<()> { imex(alice, ImexMode::ExportSelfKeys, export_dir.path(), None).await?; // We need Bob only to encrypt the forged message to Alice's key, actually Bob doesn't // participate in the scenario. - let bob = &TestContext::new().await; + let bob = &tcm.unconfigured().await; assert_eq!(crate::key::load_self_secret_keyring(bob).await?.len(), 0); bob.configure_addr("bob@example.net").await; imex(bob, ImexMode::ImportSelfKeys, export_dir.path(), None).await?; assert_eq!(crate::key::load_self_secret_keyring(bob).await?.len(), 1); - let malice = &TestContext::new().await; + let malice = &tcm.unconfigured().await; malice.configure_addr(alice_addr).await; let malice_chat_id = tcm @@ -4509,9 +4509,8 @@ async fn test_outgoing_msg_forgery() -> Result<()> { assert_eq!(crate::key::load_self_secret_keyring(bob).await?.len(), 1); let sent_msg = malice.send_text(malice_chat_id, "hi from malice").await; - let msg = alice.recv_msg(&sent_msg).await; - assert_eq!(msg.state, MessageState::OutDelivered); - assert!(!msg.get_showpadlock()); + let msg = alice.recv_msg_opt(&sent_msg).await; + assert!(msg.is_none()); Ok(()) } diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index cb64beb5f..1963123c3 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -17,7 +17,7 @@ use crate::key::DcKey; use crate::log::warn; use crate::provider::get_provider_info; use crate::sql::Sql; -use crate::tools::{Time, inc_and_check, time_elapsed}; +use crate::tools::{self, Time, inc_and_check, time_elapsed}; use crate::transport::ConfiguredLoginParam; const DBVERSION: i32 = 68; @@ -2385,6 +2385,55 @@ UPDATE msgs SET state=24 WHERE state=18; -- Change OutPreparing to OutFailed. .await?; } + inc_and_check(&mut migration_version, 153)?; + if dbversion < migration_version { + sql.execute_migration_transaction( + |transaction| { + // Newest timestamp of message sent to unencrypted chat with contacts. + // This is for 1:1 chats and ad hoc groups. + // + // Corner case of ad hoc groups with only self as a member is ignored. + let max_unencrypted_timestamp: i64 = transaction.query_row( + "SELECT IFNULL(MAX(msgs.timestamp), 0) + FROM msgs + INNER JOIN chats_contacts + ON chats_contacts.chat_id = msgs.chat_id + INNER JOIN contacts + ON chats_contacts.contact_id = contacts.id + WHERE contacts.id > 9 + AND contacts.fingerprint = '' + ", (), + |row| row.get(0) + ).context("Failed to select largest unencrypted message timestamp")?; + + // Find the newest unencrypted mailing list message. + // Mailing lists have only self-contact as a member, + // so we look for them separately. + let max_mailing_list_timestamp: i64 = + transaction.query_row( + "SELECT IFNULL(MAX(msgs.timestamp), 0) + FROM msgs + INNER JOIN chats ON chats.id = msgs.chat_id + WHERE chats.type = 140", + (), |row| row.get(0)).context("Failed to select largest mailing list timestamp")?; + + let now = tools::time(); + let max_unencrypted_timestamp = std::cmp::max(max_unencrypted_timestamp, max_mailing_list_timestamp); + if max_unencrypted_timestamp.saturating_add(3600 * 24 * 90) > now { + // There are recent active unencrypted chats, don't enforce encryption. + // Otherwise `force_encryption` is enabled by default. + transaction + .execute( + "INSERT OR REPLACE INTO config (keyname, value) VALUES ('force_encryption', '0')", () + )?; + } + Ok(()) + }, + migration_version, + ) + .await?; + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await? diff --git a/src/sync.rs b/src/sync.rs index 8b050456d..3f386bdea 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -810,4 +810,23 @@ mod tests { assert_eq!(msg.text, "Member Me added by alice@example.org."); Ok(()) } + + /// Tests that "force encryption" setting is synced. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_sync_force_encryption() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let alice2 = &tcm.alice().await; + alice.set_config_bool(Config::SyncMsgs, true).await?; + alice2.set_config_bool(Config::SyncMsgs, true).await?; + + assert_eq!(alice.get_config_bool(Config::ForceEncryption).await?, true); + alice2 + .set_config_bool(Config::ForceEncryption, false) + .await?; + test_utils::sync(alice2, alice).await; + assert_eq!(alice.get_config_bool(Config::ForceEncryption).await?, false); + + Ok(()) + } } diff --git a/src/test_utils.rs b/src/test_utils.rs index 3df12c386..20d886486 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1231,9 +1231,8 @@ ORDER BY id" } /// Allow reception of unencrypted messages. - #[expect(clippy::unused_async)] pub async fn allow_unencrypted(&self) -> Result<()> { - // Does nothing for now. + self.set_config_bool(Config::ForceEncryption, false).await?; Ok(()) } }