feat: add option to force encryption

This commit is contained in:
link2xt
2026-05-07 07:10:17 +02:00
parent 93ac040194
commit ae4c3f34ab
15 changed files with 144 additions and 19 deletions

View File

@@ -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:

View File

@@ -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))

View File

@@ -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 <x@example.org>", "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):

View File

@@ -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")

View File

@@ -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) => {

View File

@@ -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,
)
}

View File

@@ -1053,6 +1053,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));

View File

@@ -109,6 +109,7 @@ 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;
@@ -155,6 +156,19 @@ 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());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chatmail_can_send_unencrypted() -> Result<()> {
let mut tcm = TestContextManager::new();

View File

@@ -1994,12 +1994,21 @@ 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())
let is_encrypted = if let Some(content_type) = headers.get_header_value(HeaderDef::ContentType)
{
mailparse::parse_content_type(&content_type).mimetype == "multipart/encrypted"
} else {
false
};
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_encrypted || !context.get_config_bool(Config::ForceEncryption).await?));
Ok(should_download)
}

View File

@@ -21,6 +21,7 @@ const PREFETCH_FLAGS: &str = "(UID RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\
DATE \
X-MICROSOFT-ORIGINAL-MESSAGE-ID \
FROM \
CONTENT-TYPE \
CHAT-VERSION \
CHAT-IS-POST-MESSAGE \
AUTOCRYPT-SETUP-MESSAGE\

View File

@@ -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());

View File

@@ -4498,12 +4498,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
@@ -4513,9 +4513,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(())
}

View File

@@ -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?

View File

@@ -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(())
}
}

View File

@@ -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(())
}
}